fix: harden reconnect and playback regressions
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user