diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index b95bdb0f..ead5d482 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -55,9 +55,6 @@ class MainActivity : FlutterActivity() { private const val API_MX_TITLE = "title" private const val API_MX_FILENAME = "filename" private const val API_MX_SECURE_URI = "secure_uri" - - private const val API_MPV_RESULT_ID = "is.xyz.mpv.MPVActivity.result" - private const val API_VLC_RESULT_POSITION = "extra_position" private const val API_VLC_RESULT_DURATION = "extra_duration" @@ -302,7 +299,6 @@ class MainActivity : FlutterActivity() { val action = data?.action val playbackCompleted = when (action) { API_MX_RESULT_ID -> extras?.getString(API_MX_RESULT_END_BY) == API_MX_RESULT_END_BY_PLAYBACK_COMPLETION - API_MPV_RESULT_ID -> endPosition == null API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_PLAYBACK_COMPLETED else -> false } diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 319b5ab8..bd2e1af4 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -138,6 +138,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi MultiServerProvider(this._serverManager, this._aggregationService) { // Listen to server status changes _statusSubscription = _serverManager.statusStream.listen((_) { + _promoteOnlineExpectedServers(); final currentOnline = Set.from(onlineServerIds); final hasNewServer = currentOnline.any((id) => !_previousOnlineServerIds.contains(id)); _previousOnlineServerIds = currentOnline; @@ -157,6 +158,17 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi }); } + void _promoteOnlineExpectedServers() { + final visible = _visibleServerIds; + final expected = _expectedVisibleServerIds; + if (visible == null || expected == null || expected.isEmpty) return; + + final onlineExpected = _serverManager.onlineServerIds.where(expected.contains).where((id) => !visible.contains(id)); + if (onlineExpected.isEmpty) return; + + _visibleServerIds = {...visible, ...onlineExpected}; + } + /// Get the multi-server manager MultiServerManager get serverManager => _serverManager; diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 987c6964..1ec4a613 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -84,6 +84,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta late final _passwordController = createTextEditingController(); final _urlFocus = FocusNode(debugLabel: 'AddJellyfin:Url'); final _findServerFocus = FocusNode(debugLabel: 'AddJellyfin:FindServer'); + final _changeServerFocus = FocusNode(debugLabel: 'AddJellyfin:ChangeServer'); final _usernameFocus = FocusNode(debugLabel: 'AddJellyfin:Username'); // Owned so the username field can advance focus on Enter; mobile keyboards // act on `textInputAction: next` automatically but TV remotes / hardware @@ -119,6 +120,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _qcAttemptId++; _urlFocus.dispose(); _findServerFocus.dispose(); + _changeServerFocus.dispose(); _usernameFocus.dispose(); _passwordFocus.dispose(); _signInFocus.dispose(); @@ -481,7 +483,9 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _clearResolvedServer(); }); }, - onNavigateDown: _serverInfo == null ? _focusFirstDiscoveredServerOrFind : () => _usernameFocus.requestFocus(), + onNavigateDown: _serverInfo == null + ? _focusFirstDiscoveredServerOrFind + : () => _changeServerFocus.requestFocus(), textInputAction: TextInputAction.go, onFieldSubmitted: busy ? null : (_) => _probe(), decoration: InputDecoration( @@ -514,7 +518,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta autocorrect: false, enableSuggestions: false, enabled: !busy, - onNavigateUp: () => _urlFocus.requestFocus(), + onNavigateUp: () => _changeServerFocus.requestFocus(), textInputAction: TextInputAction.next, onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(), decoration: InputDecoration( @@ -593,13 +597,24 @@ class _AddJellyfinScreenState extends State with AsyncFormSta ], ), ), - TextButton( + FocusableButton( + focusNode: _changeServerFocus, + useBackgroundFocus: true, + onNavigateUp: () => _urlFocus.requestFocus(), + onNavigateDown: () => _usernameFocus.requestFocus(), onPressed: busy ? null : () => setState(() { _clearResolvedServer(); }), - child: Text(t.addServer.change), + child: TextButton( + onPressed: busy + ? null + : () => setState(() { + _clearResolvedServer(); + }), + child: Text(t.addServer.change), + ), ), ], ), diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index e53be5a8..e54afbe1 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -427,7 +427,10 @@ class _JellyfinFailoverHttpClient extends MediaServerHttpClient { } if (!manager.hasFallback) { - manager.resetToFirst(); + final resetBaseUrl = manager.resetToFirst(); + if (resetBaseUrl != null) { + await onEndpointSwitch(resetBaseUrl, persist: false); + } throw MediaServerHttpException( type: MediaServerHttpErrorType.connectionError, message: 'All Jellyfin endpoints exhausted', @@ -457,8 +460,24 @@ class _JellyfinFailoverHttpClient extends MediaServerHttpClient { if (response.statusCode < 400) { appLogger.i('Jellyfin endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl}); await onEndpointSwitch(nextBaseUrl, persist: true); + } else if (_shouldAttemptFailover(statusCode: response.statusCode) && !manager.hasFallback) { + final resetBaseUrl = manager.resetToFirst(); + if (resetBaseUrl != null) { + await onEndpointSwitch(resetBaseUrl, persist: false); + } + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: response.statusCode, + message: 'All Jellyfin endpoints exhausted', + ); } return response; + } catch (_) { + final resetBaseUrl = manager.resetToFirst(); + if (resetBaseUrl != null) { + await onEndpointSwitch(resetBaseUrl, persist: false); + } + rethrow; } finally { _failoverSwitching = false; } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 34c0ed1f..c5146730 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -415,7 +415,10 @@ class PlexClient } if (!_endpointManager.hasFallback) { - _endpointManager.resetToFirst(); + final resetBaseUrl = _endpointManager.resetToFirst(); + if (resetBaseUrl != null) { + await _handleEndpointSwitch(resetBaseUrl, persist: false); + } _onAllEndpointsExhausted?.call(); rethrow; } @@ -442,6 +445,13 @@ class PlexClient appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl}); await _onEndpointChanged?.call(nextBaseUrl); return response; + } catch (_) { + final resetBaseUrl = _endpointManager.resetToFirst(); + if (resetBaseUrl != null) { + await _handleEndpointSwitch(resetBaseUrl, persist: false); + } + _onAllEndpointsExhausted?.call(); + rethrow; } finally { _failoverSwitching = false; } diff --git a/lib/services/watch_state_resolver.dart b/lib/services/watch_state_resolver.dart index 1bfbde88..57384ea1 100644 --- a/lib/services/watch_state_resolver.dart +++ b/lib/services/watch_state_resolver.dart @@ -37,11 +37,7 @@ class WatchStateResolver { WatchStateChangeType.progressUpdate => event.isNowWatched == true ? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0) - : WatchStateSnapshot( - isWatched: false, - hasViewOffsetMs: event.viewOffset != null, - viewOffsetMs: event.viewOffset, - ), + : WatchStateSnapshot(hasViewOffsetMs: event.viewOffset != null, viewOffsetMs: event.viewOffset), WatchStateChangeType.removedFromContinueWatching => const WatchStateSnapshot(), }; } @@ -62,11 +58,7 @@ class WatchStateResolver { 'progress' => latest!.shouldMarkWatched ? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0) - : WatchStateSnapshot( - isWatched: false, - hasViewOffsetMs: latest.viewOffset != null, - viewOffsetMs: latest.viewOffset, - ), + : WatchStateSnapshot(hasViewOffsetMs: latest.viewOffset != null, viewOffsetMs: latest.viewOffset), _ => const WatchStateSnapshot(), }; } diff --git a/lib/utils/endpoint_failover_interceptor.dart b/lib/utils/endpoint_failover_interceptor.dart index f50c64f1..d61bef82 100644 --- a/lib/utils/endpoint_failover_interceptor.dart +++ b/lib/utils/endpoint_failover_interceptor.dart @@ -30,12 +30,14 @@ class EndpointFailoverManager { /// Reset back to the first (preferred) endpoint. Called when all endpoints /// are exhausted so the next failure cycle starts from the best candidate. - void resetToFirst() { + String? resetToFirst() { if (_currentIndex != 0) { _currentIndex = 0; _generation++; appLogger.d('Failover endpoint list reset to first candidate'); + return _endpoints[_currentIndex]; } + return null; } /// Replace the endpoint list and optionally set the active endpoint. diff --git a/pubspec.yaml b/pubspec.yaml index 5ff1fe1d..53156ffc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plezy description: "A beautiful Plex and Jellyfin client for Flutter" publish_to: "none" -version: 2.3.0+102 +version: 2.3.1+104 environment: sdk: ">=3.12.0 <4.0.0" diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 952d3bc6..9578ca60 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -803,7 +803,7 @@ void main() { await Future.delayed(Duration.zero); final updated = p.getMetadata('srv:42'); - expect(updated?.isWatched, isFalse); + expect(updated?.isWatched, isTrue); expect(updated?.viewOffsetMs, 50000); p.dispose(); diff --git a/test/providers/multi_server_provider_test.dart b/test/providers/multi_server_provider_test.dart index 7c0fe47b..92eb855f 100644 --- a/test/providers/multi_server_provider_test.dart +++ b/test/providers/multi_server_provider_test.dart @@ -213,6 +213,28 @@ void main() { p.dispose(); }); + + test('expected servers become visible when they reconnect', () async { + final p = MultiServerProvider(manager, aggregation); + final onlineCalls = >[]; + p.onOnlineServersChanged = onlineCalls.add; + + p.setVisibleServerIds({'srv-1'}); + p.setExpectedVisibleServerIds({'srv-1', 'srv-2'}); + manager.updateServerStatus('srv-1', true); + await Future.delayed(Duration.zero); + + expect(p.onlineServerIds, ['srv-1']); + + manager.updateServerStatus('srv-2', true); + await Future.delayed(Duration.zero); + + expect(p.onlineServerIds, containsAllInOrder(['srv-1', 'srv-2'])); + expect(p.isServerOnline('srv-2'), isTrue); + expect(onlineCalls.last, {'srv-1', 'srv-2'}); + + p.dispose(); + }); }); test('dispose runs cleanly and cancels the status subscription', () async { diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 707200c5..15711006 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -152,7 +152,7 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-1'); }); - testWidgets('D-pad moves from URL to credentials after server is found', (tester) async { + testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async { await tester.pumpWidget( MaterialApp( home: AddJellyfinScreen( @@ -177,11 +177,21 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:ChangeServer'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Username'); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:ChangeServer'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); }); diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index bf2bc8a7..d6eb2be0 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -108,4 +108,34 @@ void main() { expect(action.duration, isNull); expect(action.shouldMarkWatched, isFalse); }); + + test('Android external progress ignores missing position without explicit completion', () async { + final client = _RecordingClient(); + + await ExternalPlayerService.reportAndroidExternalProgressForTesting( + positionMs: null, + durationMs: 100000, + playbackCompleted: false, + metadata: _item(durationMs: 100000), + client: client, + ); + + expect(client.started, isEmpty); + expect(client.stopped, isEmpty); + }); + + test('Android external progress reports full duration for explicit completion', () async { + final client = _RecordingClient(); + + await ExternalPlayerService.reportAndroidExternalProgressForTesting( + positionMs: null, + durationMs: 100000, + playbackCompleted: true, + metadata: _item(durationMs: 100000), + client: client, + ); + + expect(client.started, [(positionMs: 100000, durationMs: 100000)]); + expect(client.stopped, [(positionMs: 100000, durationMs: 100000)]); + }); } diff --git a/test/services/jellyfin_client_failures_test.dart b/test/services/jellyfin_client_failures_test.dart index ab70a66a..4eb6414c 100644 --- a/test/services/jellyfin_client_failures_test.dart +++ b/test/services/jellyfin_client_failures_test.dart @@ -164,5 +164,31 @@ void main() { expect(client.connection.baseUrl, 'https://fallback.example.com'); expect(client.connection.baseUrls, ['https://fallback.example.com', 'https://primary.example.com']); }); + + test('resets live base URL after fallback endpoint is exhausted', () async { + final requests = []; + final client = JellyfinClient.forTesting( + connection: _conn( + baseUrl: 'https://primary.example.com', + baseUrls: const ['https://primary.example.com', 'https://fallback.example.com'], + ), + httpClient: MockClient((req) async { + requests.add(req.url); + if (requests.length <= 2) { + throw TimeoutException('endpoint down'); + } + return http.Response(jsonEncode({'Id': 'srv-1'}), 200, headers: {'content-type': 'application/json'}); + }), + ); + addTearDown(client.close); + + await client.getMachineIdentifier(); + + expect(requests.map((uri) => uri.host), ['primary.example.com', 'fallback.example.com']); + expect(client.connection.baseUrl, 'https://primary.example.com'); + + expect(await client.getMachineIdentifier(), 'srv-1'); + expect(requests.map((uri) => uri.host), ['primary.example.com', 'fallback.example.com', 'primary.example.com']); + }); }); } diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index f7ea4d6d..846f537f 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -520,7 +520,7 @@ void main() { expect(await svc.getLocalWatchStatus('srv:1'), isFalse); }); - test('returns true only for progress that crossed the watched threshold', () async { + test('returns watched status only for explicit actions or threshold-crossing progress', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); addTearDown(() async { svc.dispose(); @@ -528,9 +528,9 @@ void main() { await db.close(); }); - // Below threshold is explicit local progress, so it overrides stale watched metadata. + // Below threshold is resume-only; it must not override stale watched metadata. await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 50, duration: 100); - expect(await svc.getLocalWatchStatus('srv:1'), isFalse); + expect(await svc.getLocalWatchStatus('srv:1'), isNull); // Above threshold → shouldMarkWatched=true → status=true. await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100); @@ -809,7 +809,7 @@ void main() { expect(await svc.getLocalWatchStatus('jf-machine:item-1'), isTrue); expect(await svc.getLocalViewOffset('jf-machine:item-1'), isNull); - expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isFalse); + expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isNull); expect(await svc.getLocalViewOffset('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 5000); }); diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index 847e3afa..4eaf9470 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -95,6 +95,40 @@ void main() { expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary)); }); + test('resets live base URL after fallback endpoint is exhausted', () async { + const primary = 'http://primary:32400'; + const fallback = 'http://fallback:32400'; + final httpClient = _SequenceClient([ + (_) async => throw TimeoutException('primary down'), + (_) async => throw TimeoutException('fallback down'), + (_) async => _jsonResponse({ + 'MediaContainer': {'machineIdentifier': 'server-id'}, + }), + ]); + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: primary, + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'server-id', + serverName: 'Server', + httpClient: httpClient, + prioritizedEndpoints: const [primary, fallback], + ); + addTearDown(client.close); + + await expectLater(client.getServerIdentity(), throwsA(isA())); + + expect(client.config.baseUrl, primary); + expect(httpClient.requests.map((r) => r.url.origin), [primary, fallback]); + + await client.getServerIdentity(); + expect(httpClient.requests.map((r) => r.url.origin), [primary, fallback, primary]); + }); + test('fetchGlobalHubs uses promoted hub endpoint advertised by media providers', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); diff --git a/test/services/watch_state_resolver_test.dart b/test/services/watch_state_resolver_test.dart index 23abacec..cfc9cdcb 100644 --- a/test/services/watch_state_resolver_test.dart +++ b/test/services/watch_state_resolver_test.dart @@ -26,13 +26,13 @@ OfflineWatchProgressItem _action({ } void main() { - test('newer sub-threshold progress overrides older watched state without watched-plus-resume', () { + test('newer sub-threshold progress preserves watched state while updating resume offset', () { final snapshot = WatchStateResolver.fromActions([ _action(actionType: 'watched', updatedAt: 1), _action(actionType: 'progress', updatedAt: 2, viewOffset: 5000, duration: 100000), ]); - expect(snapshot.isWatched, isFalse); + expect(snapshot.isWatched, isNull); expect(snapshot.hasViewOffsetMs, isTrue); expect(snapshot.viewOffsetMs, 5000); }); @@ -48,7 +48,7 @@ void main() { expect(snapshot.viewOffsetMs, 0); }); - test('sub-threshold progress events explicitly clear watched state', () { + test('sub-threshold progress events only update resume offset', () { final snapshot = WatchStateResolver.fromEvent( WatchStateEvent( itemId: 'item-1', @@ -61,7 +61,8 @@ void main() { ), ); - expect(snapshot.isWatched, isFalse); + expect(snapshot.isWatched, isNull); + expect(snapshot.hasViewOffsetMs, isTrue); expect(snapshot.viewOffsetMs, 5000); });