bugfix-offline-playback - Sync offline progress once a server session is ready, scoped to that server - #1621
Merged
RadicalMuffinMan merged 3 commits intoSep 27, 2026
Conversation
…erver syncPlaybackProgress and refreshMetadata read every download row whatever its server. Jellyfin answers a stop report for an item it does not have with success, so syncing while signed into another server marked the first server's offline progress synced without that server ever getting it. Both now take the active server id, the way syncPendingRatings already does, and only touch that server's downloads. A row stored under the server's address instead of its id still matches. With no active server nothing is pushed. Adds the first tests for the furthest-progress-wins rules from Moonfin-Client#560 and for the cross-server case.
…fin-Client#1603 The offline progress sync only ran when the server went from unreachable to reachable. On a cold start with the network already up, the boot-time attempt ran before any server client existed and was dropped, and nothing retried it. The app then showed the server's older position, and playing from it overwrote the further one watched offline. setActiveServerClient now tells ConnectivityService that a client is ready, with its server id, and the service probes the server and runs the ratings, progress and metadata sync. Every sign-in path goes through it: session restore, the login screen, Quick Connect, account and server switches, and deep links. The background auto-download worker is skipped, and a backgrounded engine still waits for the app to come to the front.
The sign-in hook, the boot-time check and a network change can all ask for the sync within a second of each other, and only the progress step guarded itself, so two runs repeated the ratings push and the whole metadata refresh. A request made while a run is going now waits for it, and a client that signed in since that run started gets its own run once it finishes, so a quick server switch still syncs the new server. The early return when no server client exists yet now leaves a network log line instead of dropping the sync silently, and a failed run is logged.
codyjohnsontx
marked this pull request as draft
September 23, 2026 17:33
codyjohnsontx
marked this pull request as ready for review
September 24, 2026 04:46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Watch progress recorded offline was lost when the app was reopened with the network already up (#1603). The sync that pushes offline progress only ran when the server went from unreachable to reachable. On a cold start that one boot-time attempt ran before any server client existed, found nothing to sync with, and was never retried. The app then showed the server's older position, and playing from it overwrote the further one.
This PR runs the existing sync (pending ratings, then playback progress, then the metadata refresh) as soon as a server client is signed in, scopes it to that server, and keeps it to one run at a time. The furthest-progress-wins rules from #560 are unchanged; they now actually run on this path.
Related Issues
Type of Change
What was wrong
ConnectivityService.initialize()runs before any server client exists._checkInitialStateprobes, finds no client, keepsserverReachableat its defaulttrue, and calls_triggerSync, which returns silently because noMediaServerClientis registered yet. After that, every other trigger needs a reachable edge that never comes on a live network: the reconnect handler only syncs when!wasReachable, andonAppResumedonly replays a sync parked while backgrounded. The startup screen'srecheckNow()probes but never syncs.A debug-print build of current main, cold-started on an iPhone simulator with offline progress on disk, showed it every time:
It works when the app stays open while the connection comes back, because that path has the unreachable-to-reachable edge. That is the path #623 was tested on.
Two more things turned up while fixing it:
syncPlaybackProgressandrefreshMetadataread every download row whatever its server. Jellyfin 12.1 answers204to a stop report for an item it does not have, so a sync while signed into server B pushed server A's offline progress to B and marked it synced, and A never received it. A trigger at sign-in would hit this on every server switch, so the scoping has to come with it.Changes Made
Three commits, each builds and passes its tests on its own:
serverId, the waysyncPendingRatingsalready does, and only touch that server's downloads. Rows store either the app's server id or the server's base URL (some download paths write_client.baseUrl), so a URL-shaped id is matched the wayMediaServerClientFactory.getClientIfExistsresolves one. With no active server nothing is pushed.ConnectivityService.onServerClientReady(serverId), called at the end ofsetActiveServerClientwithserverIdOf(rawClient). It probes the server, then runs the existing sync chain. Every sign-in path goes through that function: session restore, the login screen, Quick Connect, account and server switches, and deep links. The login screen, Quick Connect, the server screen and deep links never reach the startup screen'srecheckNow(). The server id comes from the caller, not fromSessionRepository.activeServerIdread after an await. The call is skipped for the background auto-download worker (background: true, andConnectivityServiceisn't registered there anyway). The existing foreground deferral still applies, so a headless Android engine parks the sync until the app is opened. On Apple TV the hook calls the probe directly rather thanrecheckNow(), so it doesn't add aconnectivity_pluscall there._inFlightSyncguard modelled on the existing_inFlightProbe. A request made while a run is going waits for it; a client that signed in since that run started (a quick server or account switch) gets its own run once it finishes. The early return when no client exists yet now writes aServerLog.networkline instead of dropping the sync silently, and a failed run is logged.No new strings, nothing under
lib/l10n/, and no new dependencies.Platform
The change is in shared Dart code, but it was tested on iOS only (the iPhone simulator, below). Android is out of scope for this PR. Android's different startup path, where a headless engine parks the sync until the app is opened, is covered by the unit test
a headless Android engine parks the sync until the app is opened.Testing
Unit tests. New
test/offline/offline_progress_sync_test.dart, 14 tests, the first on this path. They use the realSyncService,OfflineRepositoryon an in-memory database andConnectivityService, with a fake server that answers the way Jellyfin does:With each part of the fix removed on purpose, the matching tests fail. The full app suite passes except
clearImageDiskCache live-clears ...intest/util/game_artwork_scope_sweep_test.dart, which fails the same way onmain(sqflite isn't set up in that test).Static checks. Full
flutter analyzeon the root and every package CI analyzes:mainand this branch both have 352 infos, 0 warnings, 0 errors, and the lists are identical, so there are no new issues of any severity.Real app, recorded. The app was driven by XCUITest on the simulator. At every step the positions were read straight from the server's REST API and from the app's own
offline.db, not from the screen.mainmeans2ac20ce19built the same way.Test Steps
The reporter's flow (Test 1):
Repeated cold starts with offline progress on disk: this PR pushed it 6 of 6 times;
main0 of 6.Two servers (Test 2, this PR): offline progress on server A stayed unsynced while signed into B (B untouched), reached A 2 s after switching back, and a cold start on B pushed B's offline progress without touching A. Server B's own log never mentions A's item.
Before/after recordings of the reporter's flow are in the issue: #1603 (comment)
Regression checks, same steps on both builds:
GET /System/PingOne visible difference, which is the metadata refresh from #560 now running at sign-in: after an item's played state changes on the server, the local download row takes the server's state on the next sign-in or restart. On
mainit kept the stale local value (for example still 0:45 and unplayed after marking it watched online), which is what offline mode would show later.Screenshots (if applicable)
The item detail screen after the cold start in Test 1. Recordings of the flow are linked in the issue.
Not in this PR (questions for the maintainers)
refreshMetadatatrusts what the server returns, so a server that answers2xxwithout moving its position would bring the older value back down. Jellyfin 12.1 did move it in testing; is this worth hardening for Emby or Jellyfin's resume thresholds?upsertItemreplaces any row with the same item id, and the position helpers update by item id, so the same item id on two servers (two servers sharing one library path, for example) can only be downloaded from one of them at a time. This PR keeps that model; making the repository server-aware would be a separate change.refreshMetadata. It fetches each completed download one by one. The same batchedgetItems(ids: ...)the progress step uses would make it one request. Now that it runs at every sign-in, is that worth doing?Checklist