fix: harden reconnect and playback regressions
This commit is contained in:
@@ -803,7 +803,7 @@ void main() {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final updated = p.getMetadata('srv:42');
|
||||
expect(updated?.isWatched, isFalse);
|
||||
expect(updated?.isWatched, isTrue);
|
||||
expect(updated?.viewOffsetMs, 50000);
|
||||
|
||||
p.dispose();
|
||||
|
||||
@@ -213,6 +213,28 @@ void main() {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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)]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = <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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user