fix(profiles): keep offline downloads visible when a profile's servers are unreachable

Switching to a profile whose only server is offline verified the PIN,
failed the bind with zero reachable servers, and rolled back to the
previous profile — whose scope owns none of the downloads. The Downloads
UI then showed nothing while the files and pinned metadata sat intact on
disk. Startup offline mode only covered the cold-start bind, so such a
profile could never be entered while its server was away.

The binder now classifies a settled bind failure as connectivity-only
when the profile expected servers, reached none, and none were
auth-rejected (snapshotting auth markers before the visibility sweep,
which clears them via removeServer). On such a failure,
switchProfileFromUi keeps the profile active when it owns downloads
instead of rolling back; OfflineModeProvider drives the offline UI from
the empty visible-server set. Auth failures, PIN cancels, and
downloads-free profiles keep the existing rollback and error snackbar.

close #1927
This commit is contained in:
edde746
2026-08-14 12:20:20 +02:00
parent cd4432f27c
commit 688bfdacb4
4 changed files with 252 additions and 0 deletions
+24
View File
@@ -91,6 +91,14 @@ class ActiveProfileBinder {
/// PIN dialog with no user action. Explicit paths ([rebindActive], a
/// user-initiated activation, a pre-verified switch) clear the marker.
String? _lastFailedProfileId;
/// True when the most recently completed rebind pass failed purely for
/// connectivity: the profile expected at least one server, reached none,
/// and no expected server was auth-rejected. Superseded/cancelled passes
/// and thrown binds leave this false. Read by `switchProfileFromUi` to
/// keep a downloads-owning profile active in offline mode instead of
/// rolling back to a profile whose scope does not own those downloads.
bool _lastBindFailureConnectivityOnly = false;
bool _pendingRebind = false;
// Set when something asks for a rebind of the *currently-active* profile
// while a rebind is already in flight. The normal `_pendingRebind` path
@@ -116,6 +124,8 @@ class ActiveProfileBinder {
@visibleForTesting
String? get debugLastBoundProfileId => _lastBoundProfileId;
bool get lastBindFailureConnectivityOnly => _lastBindFailureConnectivityOnly;
void markPlexHomePreVerified(String profileId) {
_plexHomePreVerified.add(profileId);
if (_lastFailedProfileId == profileId) _lastFailedProfileId = null;
@@ -255,6 +265,7 @@ class ActiveProfileBinder {
final generation = ++_bindGeneration;
final stopwatch = Stopwatch()..start();
var success = false;
_lastBindFailureConnectivityOnly = false;
String? attemptedProfileId;
try {
final profile = activeProfile.active;
@@ -334,6 +345,12 @@ class ActiveProfileBinder {
expectedServerIds.addAll(result.expectedServerIds);
}
// Snapshot before the visibility sweep below: removeServer() clears a
// swept server's auth-error marker, and an auth-rejected server is by
// definition not visible — reading authErrorServerIds after the sweep
// would misclassify a revoked token as a connectivity failure.
final authErrorServerIds = serverManager.authErrorServerIds;
// Remove servers the profile no longer has access to. Always set the
// filter to the bound set (even when empty) so a profile with no
// connections shows nothing — falling back to "all visible" on empty
@@ -346,6 +363,13 @@ class ActiveProfileBinder {
multiServerProvider.setExpectedVisibleServerIds(expectedServerIds);
multiServerProvider.setVisibleServerIds(visibleServerIds);
success = (profile.isLocal && !localProfileHasJoinRows) || visibleServerIds.isNotEmpty;
if (!success) {
// A failed pass that expected servers, reached none (`!success`
// implies `visibleServerIds.isEmpty` here), and saw no auth
// rejection is offline, not misconfigured or revoked.
_lastBindFailureConnectivityOnly =
expectedServerIds.isNotEmpty && expectedServerIds.every((id) => !authErrorServerIds.contains(id));
}
// Once we've bound a profile with real servers in this session,
// we've crossed the cold-start boundary — every subsequent rebind
// is a user-initiated switch and must re-prompt for PIN where
+21
View File
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
import '../connection/connection.dart';
import '../connection/connection_registry.dart';
import '../database/app_database.dart';
import '../database/download_operations.dart';
import '../i18n/strings.g.dart';
import '../screens/profile/pin_entry_dialog.dart';
import '../utils/snackbar_helper.dart';
@@ -171,6 +173,25 @@ Future<bool> switchProfileFromUi(BuildContext context, Profile profile) async {
if (!isCurrentActivation(profile.id)) return false;
if (bound) return true;
if (binder.lastBindFailureConnectivityOnly && context.mounted) {
// The bind failed purely for connectivity — identity was already verified
// before activation (local PIN hash or the Plex /switch round-trip). When
// the profile owns downloads, keep it active instead of rolling back: the
// rollback profile's scope does not own them, so they would vanish from
// the Downloads UI while the files sit on disk. OfflineModeProvider drives
// the offline UI from the empty visible-server set. Auth-classified bind
// failures never set the flag and keep today's rollback + snackbar.
final ownedDownloadKeys = await context.read<AppDatabase>().getDownloadOwnerKeysForProfile(profile.id);
if (!isCurrentActivation(profile.id)) return false;
if (ownedDownloadKeys.isNotEmpty) {
appLogger.i(
'Profile switch: keeping ${profile.displayName} active with no reachable servers '
'(${ownedDownloadKeys.length} offline downloads owned)',
);
return true;
}
}
if (previousProfile != null && previousProfile.id != profile.id && isCurrentActivation(profile.id)) {
final rollbackRequestGeneration = activeProvider.beginIdentityMutationRequest();
try {
@@ -242,6 +242,111 @@ void main() {
expect(multiServerProvider.expectedServerIds, ['srv-1']);
});
group('connectivity-only bind failure classification', () {
/// Rebuild the binder around [newManager] and seed one local profile with
/// a Plex join row whose account has a single cached server (`srv-1`).
Future<void> setUpPlexProfileWithManager(MultiServerManager newManager) async {
binder.dispose();
multiServerProvider.dispose();
manager = newManager;
multiServerProvider = testMultiServerProvider(manager);
binder = ActiveProfileBinder(
activeProfile: activeProfile,
connections: connections,
profileConnections: profileConnections,
serverManager: manager,
multiServerProvider: multiServerProvider,
pinPrompt: (_, {String? errorMessage}) async => null,
shouldDeferInitialBind: (_) async => false,
plexAuth: PlexAuthService.forTesting(
http: MediaServerHttpClient(
client: MockClient(
(_) async =>
http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'}),
),
),
),
);
final profile = await createActiveLocalProfile('local-classification');
final account = PlexAccountConnection(
id: 'plex.account',
accountToken: 'account-token',
clientIdentifier: 'client-id',
accountLabel: 'Owner',
servers: [_server(accessToken: 'account-server-token')],
createdAt: DateTime(2026, 1, 1),
);
await connections.upsert(account);
await profileConnections.upsert(
ProfileConnection(
profileId: profile.id,
connectionId: account.id,
userToken: 'home-user-token',
userIdentifier: 'home-user-uuid',
tokenAcquiredAt: DateTime(2026, 1, 1),
),
);
}
test('unreachable servers with no auth rejection classify as connectivity-only', () async {
await setUpPlexProfileWithManager(_FailingPlexMultiServerManager());
await binder.rebindActive();
expect(activeProfile.lastBindingSucceeded, isFalse);
expect(binder.lastBindFailureConnectivityOnly, isTrue);
});
test('auth-rejected server disqualifies connectivity-only classification', () async {
await setUpPlexProfileWithManager(_AuthRejectingPlexManager());
await binder.rebindActive();
expect(activeProfile.lastBindingSucceeded, isFalse);
expect(binder.lastBindFailureConnectivityOnly, isFalse);
});
test('auth marker cleared by the visibility sweep still disqualifies', () async {
await setUpPlexProfileWithManager(_SweptAuthRejectingPlexManager());
await binder.rebindActive();
expect(activeProfile.lastBindingSucceeded, isFalse);
// The sweep removed the auth-errored server (and its marker) from the
// manager before success was computed; classification must have read
// the pre-sweep snapshot to see the rejection.
expect(manager.authErrorServerIds, isEmpty);
expect(binder.lastBindFailureConnectivityOnly, isFalse);
});
test('plex home profile missing parent metadata is not connectivity-only', () async {
final profile = Profile.plexHome(id: 'plex-home-broken', displayName: 'Broken', createdAt: DateTime(2026, 1, 1));
await profiles.upsert(profile);
await activeProfile.initialize();
await activeProfile.activate(profile);
await binder.rebindActive();
expect(activeProfile.lastBindingSucceeded, isFalse);
expect(binder.lastBindFailureConnectivityOnly, isFalse);
});
test('a later successful cycle resets the classification', () async {
await setUpPlexProfileWithManager(_FailingPlexMultiServerManager());
await binder.rebindActive();
expect(binder.lastBindFailureConnectivityOnly, isTrue);
final fallback = Profile.local(id: 'local-fallback', displayName: 'Fallback', createdAt: DateTime(2026, 1, 2));
await profiles.upsert(fallback);
await activeProfile.activate(fallback);
await binder.rebindActive();
expect(activeProfile.lastBindingSucceeded, isTrue);
expect(binder.lastBindFailureConnectivityOnly, isFalse);
});
});
test('binds Plex and Jellyfin join rows in parallel', () async {
binder.dispose();
multiServerProvider.dispose();
@@ -1114,6 +1219,46 @@ class _CapturingMultiServerManager extends MultiServerManager {
}
}
/// Simulates per-server connect 401s in `refreshTokensForProfile`: the server
/// is marked auth-rejected and nothing binds.
class _AuthRejectingPlexManager extends MultiServerManager {
@override
Future<Set<String>> refreshTokensForProfile(
PlexAccountConnection connection, {
required String profileId,
Duration timeout = MediaServerTimeouts.perServerConnect,
}) async {
for (final server in connection.servers) {
debugMarkAuthErrorForTesting(ServerId(server.clientIdentifier));
}
return const {};
}
}
/// Like [_AuthRejectingPlexManager], but also surfaces the rejected server to
/// the binder's visibility sweep via [serverIds] — mirroring a pre-registered
/// client whose token was rejected mid-refresh (it stays in `serverIds` until
/// swept, and `removeServer` then clears its auth marker).
class _SweptAuthRejectingPlexManager extends MultiServerManager {
final Set<String> _rejected = {};
@override
List<String> get serverIds => {...super.serverIds, ..._rejected}.toList();
@override
Future<Set<String>> refreshTokensForProfile(
PlexAccountConnection connection, {
required String profileId,
Duration timeout = MediaServerTimeouts.perServerConnect,
}) async {
for (final server in connection.servers) {
_rejected.add(server.clientIdentifier);
debugMarkAuthErrorForTesting(ServerId(server.clientIdentifier));
}
return const {};
}
}
class _FailingPlexMultiServerManager extends MultiServerManager {
int refreshCalls = 0;
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/profiles/active_profile_binder.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
@@ -37,6 +38,9 @@ class _Binder implements ActiveProfileBinder {
_Binder(this.events);
final List<String> events;
@override
bool lastBindFailureConnectivityOnly = false;
@override
void markUserInitiatedActivation(String profileId) => events.add('mark:$profileId');
@@ -56,6 +60,8 @@ class _RollbackBinder implements ActiveProfileBinder {
final Completer<void> targetBindingStarted = Completer<void>();
final Completer<void>? targetBindingRelease;
String? boundClientProfileId;
@override
bool lastBindFailureConnectivityOnly = false;
bool _targetFailed = false;
final Set<String> _successfullyBoundProfileIds = {};
@@ -250,6 +256,61 @@ void main() {
expect(SystemShelfService().debugActiveOwner, 'owner');
});
testWidgets('connectivity-only bind failure keeps a downloads-owning profile active offline', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
binder.lastBindFailureConnectivityOnly = true;
await harness.database.addDownloadOwner(profileId: 'target', globalKey: 'srv-1/episode-1');
final activated = await switchProfileFromUi(harness.context, harness.target);
expect(activated, isTrue);
expect(harness.active.activeId, 'target');
expect(SystemShelfService().debugActiveOwner, 'target');
expect((await StorageService.getInstance()).getActiveProfileId(), 'target');
expect(binder.rebindStarted.isCompleted, isFalse);
expect(harness.events, isNot(contains('rebind:owner')));
await tester.pump();
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('connectivity-only bind failure without owned downloads still rolls back', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
binder.lastBindFailureConnectivityOnly = true;
final switchFuture = switchProfileFromUi(harness.context, harness.target);
await binder.rebindStarted.future;
binder.allowRebind.complete();
expect(await switchFuture, isFalse);
expect(harness.active.activeId, 'owner');
expect(binder.boundClientProfileId, 'owner');
expect(SystemShelfService().debugActiveOwner, 'owner');
await tester.pump();
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('non-connectivity bind failure rolls back even when the profile owns downloads', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
await harness.database.addDownloadOwner(profileId: 'target', globalKey: 'srv-1/episode-1');
final switchFuture = switchProfileFromUi(harness.context, harness.target);
await binder.rebindStarted.future;
binder.allowRebind.complete();
expect(await switchFuture, isFalse);
expect(harness.active.activeId, 'owner');
expect(binder.boundClientProfileId, 'owner');
expect(SystemShelfService().debugActiveOwner, 'owner');
await tester.pump();
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('protected switch rolls back to profile active when its activation is admitted', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true);
addTearDown(harness.dispose);
@@ -660,6 +721,7 @@ Future<_Harness> _pumpHarness(
providers: [
ChangeNotifierProvider<ActiveProfileProvider>.value(value: active),
Provider<ActiveProfileBinder>.value(value: binder),
Provider<AppDatabase>.value(value: database),
],
child: MaterialApp(
home: Scaffold(