fix: harden reconnect and playback regressions

This commit is contained in:
edde746
2026-05-31 20:33:38 +02:00
parent c6c76d6828
commit d5c8778074
16 changed files with 201 additions and 32 deletions
@@ -55,9 +55,6 @@ class MainActivity : FlutterActivity() {
private const val API_MX_TITLE = "title" private const val API_MX_TITLE = "title"
private const val API_MX_FILENAME = "filename" private const val API_MX_FILENAME = "filename"
private const val API_MX_SECURE_URI = "secure_uri" 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_POSITION = "extra_position"
private const val API_VLC_RESULT_DURATION = "extra_duration" private const val API_VLC_RESULT_DURATION = "extra_duration"
@@ -302,7 +299,6 @@ class MainActivity : FlutterActivity() {
val action = data?.action val action = data?.action
val playbackCompleted = when (action) { val playbackCompleted = when (action) {
API_MX_RESULT_ID -> extras?.getString(API_MX_RESULT_END_BY) == API_MX_RESULT_END_BY_PLAYBACK_COMPLETION 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 API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_PLAYBACK_COMPLETED
else -> false else -> false
} }
+12
View File
@@ -138,6 +138,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
MultiServerProvider(this._serverManager, this._aggregationService) { MultiServerProvider(this._serverManager, this._aggregationService) {
// Listen to server status changes // Listen to server status changes
_statusSubscription = _serverManager.statusStream.listen((_) { _statusSubscription = _serverManager.statusStream.listen((_) {
_promoteOnlineExpectedServers();
final currentOnline = Set<String>.from(onlineServerIds); final currentOnline = Set<String>.from(onlineServerIds);
final hasNewServer = currentOnline.any((id) => !_previousOnlineServerIds.contains(id)); final hasNewServer = currentOnline.any((id) => !_previousOnlineServerIds.contains(id));
_previousOnlineServerIds = currentOnline; _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 /// Get the multi-server manager
MultiServerManager get serverManager => _serverManager; MultiServerManager get serverManager => _serverManager;
+19 -4
View File
@@ -84,6 +84,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
late final _passwordController = createTextEditingController(); late final _passwordController = createTextEditingController();
final _urlFocus = FocusNode(debugLabel: 'AddJellyfin:Url'); final _urlFocus = FocusNode(debugLabel: 'AddJellyfin:Url');
final _findServerFocus = FocusNode(debugLabel: 'AddJellyfin:FindServer'); final _findServerFocus = FocusNode(debugLabel: 'AddJellyfin:FindServer');
final _changeServerFocus = FocusNode(debugLabel: 'AddJellyfin:ChangeServer');
final _usernameFocus = FocusNode(debugLabel: 'AddJellyfin:Username'); final _usernameFocus = FocusNode(debugLabel: 'AddJellyfin:Username');
// Owned so the username field can advance focus on Enter; mobile keyboards // Owned so the username field can advance focus on Enter; mobile keyboards
// act on `textInputAction: next` automatically but TV remotes / hardware // act on `textInputAction: next` automatically but TV remotes / hardware
@@ -119,6 +120,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
_qcAttemptId++; _qcAttemptId++;
_urlFocus.dispose(); _urlFocus.dispose();
_findServerFocus.dispose(); _findServerFocus.dispose();
_changeServerFocus.dispose();
_usernameFocus.dispose(); _usernameFocus.dispose();
_passwordFocus.dispose(); _passwordFocus.dispose();
_signInFocus.dispose(); _signInFocus.dispose();
@@ -481,7 +483,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
_clearResolvedServer(); _clearResolvedServer();
}); });
}, },
onNavigateDown: _serverInfo == null ? _focusFirstDiscoveredServerOrFind : () => _usernameFocus.requestFocus(), onNavigateDown: _serverInfo == null
? _focusFirstDiscoveredServerOrFind
: () => _changeServerFocus.requestFocus(),
textInputAction: TextInputAction.go, textInputAction: TextInputAction.go,
onFieldSubmitted: busy ? null : (_) => _probe(), onFieldSubmitted: busy ? null : (_) => _probe(),
decoration: InputDecoration( decoration: InputDecoration(
@@ -514,7 +518,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
enabled: !busy, enabled: !busy,
onNavigateUp: () => _urlFocus.requestFocus(), onNavigateUp: () => _changeServerFocus.requestFocus(),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(), onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(),
decoration: InputDecoration( decoration: InputDecoration(
@@ -593,13 +597,24 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
], ],
), ),
), ),
TextButton( FocusableButton(
focusNode: _changeServerFocus,
useBackgroundFocus: true,
onNavigateUp: () => _urlFocus.requestFocus(),
onNavigateDown: () => _usernameFocus.requestFocus(),
onPressed: busy onPressed: busy
? null ? null
: () => setState(() { : () => setState(() {
_clearResolvedServer(); _clearResolvedServer();
}), }),
child: Text(t.addServer.change), child: TextButton(
onPressed: busy
? null
: () => setState(() {
_clearResolvedServer();
}),
child: Text(t.addServer.change),
),
), ),
], ],
), ),
+20 -1
View File
@@ -427,7 +427,10 @@ class _JellyfinFailoverHttpClient extends MediaServerHttpClient {
} }
if (!manager.hasFallback) { if (!manager.hasFallback) {
manager.resetToFirst(); final resetBaseUrl = manager.resetToFirst();
if (resetBaseUrl != null) {
await onEndpointSwitch(resetBaseUrl, persist: false);
}
throw MediaServerHttpException( throw MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError, type: MediaServerHttpErrorType.connectionError,
message: 'All Jellyfin endpoints exhausted', message: 'All Jellyfin endpoints exhausted',
@@ -457,8 +460,24 @@ class _JellyfinFailoverHttpClient extends MediaServerHttpClient {
if (response.statusCode < 400) { if (response.statusCode < 400) {
appLogger.i('Jellyfin endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl}); appLogger.i('Jellyfin endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl});
await onEndpointSwitch(nextBaseUrl, persist: true); 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; return response;
} catch (_) {
final resetBaseUrl = manager.resetToFirst();
if (resetBaseUrl != null) {
await onEndpointSwitch(resetBaseUrl, persist: false);
}
rethrow;
} finally { } finally {
_failoverSwitching = false; _failoverSwitching = false;
} }
+11 -1
View File
@@ -415,7 +415,10 @@ class PlexClient
} }
if (!_endpointManager.hasFallback) { if (!_endpointManager.hasFallback) {
_endpointManager.resetToFirst(); final resetBaseUrl = _endpointManager.resetToFirst();
if (resetBaseUrl != null) {
await _handleEndpointSwitch(resetBaseUrl, persist: false);
}
_onAllEndpointsExhausted?.call(); _onAllEndpointsExhausted?.call();
rethrow; rethrow;
} }
@@ -442,6 +445,13 @@ class PlexClient
appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl}); appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl});
await _onEndpointChanged?.call(nextBaseUrl); await _onEndpointChanged?.call(nextBaseUrl);
return response; return response;
} catch (_) {
final resetBaseUrl = _endpointManager.resetToFirst();
if (resetBaseUrl != null) {
await _handleEndpointSwitch(resetBaseUrl, persist: false);
}
_onAllEndpointsExhausted?.call();
rethrow;
} finally { } finally {
_failoverSwitching = false; _failoverSwitching = false;
} }
+2 -10
View File
@@ -37,11 +37,7 @@ class WatchStateResolver {
WatchStateChangeType.progressUpdate => WatchStateChangeType.progressUpdate =>
event.isNowWatched == true event.isNowWatched == true
? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0) ? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0)
: WatchStateSnapshot( : WatchStateSnapshot(hasViewOffsetMs: event.viewOffset != null, viewOffsetMs: event.viewOffset),
isWatched: false,
hasViewOffsetMs: event.viewOffset != null,
viewOffsetMs: event.viewOffset,
),
WatchStateChangeType.removedFromContinueWatching => const WatchStateSnapshot(), WatchStateChangeType.removedFromContinueWatching => const WatchStateSnapshot(),
}; };
} }
@@ -62,11 +58,7 @@ class WatchStateResolver {
'progress' => 'progress' =>
latest!.shouldMarkWatched latest!.shouldMarkWatched
? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0) ? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0)
: WatchStateSnapshot( : WatchStateSnapshot(hasViewOffsetMs: latest.viewOffset != null, viewOffsetMs: latest.viewOffset),
isWatched: false,
hasViewOffsetMs: latest.viewOffset != null,
viewOffsetMs: latest.viewOffset,
),
_ => const WatchStateSnapshot(), _ => const WatchStateSnapshot(),
}; };
} }
+3 -1
View File
@@ -30,12 +30,14 @@ class EndpointFailoverManager {
/// Reset back to the first (preferred) endpoint. Called when all endpoints /// Reset back to the first (preferred) endpoint. Called when all endpoints
/// are exhausted so the next failure cycle starts from the best candidate. /// are exhausted so the next failure cycle starts from the best candidate.
void resetToFirst() { String? resetToFirst() {
if (_currentIndex != 0) { if (_currentIndex != 0) {
_currentIndex = 0; _currentIndex = 0;
_generation++; _generation++;
appLogger.d('Failover endpoint list reset to first candidate'); appLogger.d('Failover endpoint list reset to first candidate');
return _endpoints[_currentIndex];
} }
return null;
} }
/// Replace the endpoint list and optionally set the active endpoint. /// Replace the endpoint list and optionally set the active endpoint.
+1 -1
View File
@@ -1,7 +1,7 @@
name: plezy name: plezy
description: "A beautiful Plex and Jellyfin client for Flutter" description: "A beautiful Plex and Jellyfin client for Flutter"
publish_to: "none" publish_to: "none"
version: 2.3.0+102 version: 2.3.1+104
environment: environment:
sdk: ">=3.12.0 <4.0.0" sdk: ">=3.12.0 <4.0.0"
+1 -1
View File
@@ -803,7 +803,7 @@ void main() {
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
final updated = p.getMetadata('srv:42'); final updated = p.getMetadata('srv:42');
expect(updated?.isWatched, isFalse); expect(updated?.isWatched, isTrue);
expect(updated?.viewOffsetMs, 50000); expect(updated?.viewOffsetMs, 50000);
p.dispose(); p.dispose();
@@ -213,6 +213,28 @@ void main() {
p.dispose(); p.dispose();
}); });
test('expected servers become visible when they reconnect', () async {
final p = MultiServerProvider(manager, aggregation);
final onlineCalls = <Set<String>>[];
p.onOnlineServersChanged = onlineCalls.add;
p.setVisibleServerIds({'srv-1'});
p.setExpectedVisibleServerIds({'srv-1', 'srv-2'});
manager.updateServerStatus('srv-1', true);
await Future<void>.delayed(Duration.zero);
expect(p.onlineServerIds, ['srv-1']);
manager.updateServerStatus('srv-2', true);
await Future<void>.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 { test('dispose runs cleanly and cancels the status subscription', () async {
@@ -152,7 +152,7 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-1'); 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( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: AddJellyfinScreen( home: AddJellyfinScreen(
@@ -177,11 +177,21 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump(); 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'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Username');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump(); 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'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
}); });
@@ -108,4 +108,34 @@ void main() {
expect(action.duration, isNull); expect(action.duration, isNull);
expect(action.shouldMarkWatched, isFalse); 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)]);
});
} }
@@ -164,5 +164,31 @@ void main() {
expect(client.connection.baseUrl, 'https://fallback.example.com'); expect(client.connection.baseUrl, 'https://fallback.example.com');
expect(client.connection.baseUrls, ['https://fallback.example.com', 'https://primary.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 = <Uri>[];
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']);
});
}); });
} }
@@ -520,7 +520,7 @@ void main() {
expect(await svc.getLocalWatchStatus('srv:1'), isFalse); 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(); final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async { addTearDown(() async {
svc.dispose(); svc.dispose();
@@ -528,9 +528,9 @@ void main() {
await db.close(); 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); 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. // Above threshold → shouldMarkWatched=true → status=true.
await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100); 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.getLocalWatchStatus('jf-machine:item-1'), isTrue);
expect(await svc.getLocalViewOffset('jf-machine:item-1'), isNull); 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); expect(await svc.getLocalViewOffset('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 5000);
}); });
+34
View File
@@ -95,6 +95,40 @@ void main() {
expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary)); 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<Object>()));
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 { test('fetchGlobalHubs uses promoted hub endpoint advertised by media providers', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory()); final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db); PlexApiCache.initialize(db);
+5 -4
View File
@@ -26,13 +26,13 @@ OfflineWatchProgressItem _action({
} }
void main() { 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([ final snapshot = WatchStateResolver.fromActions([
_action(actionType: 'watched', updatedAt: 1), _action(actionType: 'watched', updatedAt: 1),
_action(actionType: 'progress', updatedAt: 2, viewOffset: 5000, duration: 100000), _action(actionType: 'progress', updatedAt: 2, viewOffset: 5000, duration: 100000),
]); ]);
expect(snapshot.isWatched, isFalse); expect(snapshot.isWatched, isNull);
expect(snapshot.hasViewOffsetMs, isTrue); expect(snapshot.hasViewOffsetMs, isTrue);
expect(snapshot.viewOffsetMs, 5000); expect(snapshot.viewOffsetMs, 5000);
}); });
@@ -48,7 +48,7 @@ void main() {
expect(snapshot.viewOffsetMs, 0); 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( final snapshot = WatchStateResolver.fromEvent(
WatchStateEvent( WatchStateEvent(
itemId: 'item-1', 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); expect(snapshot.viewOffsetMs, 5000);
}); });