fix(servers): stop rebinds from flashing an empty home screen after sign-in
Signing in triggered two back-to-back profile rebinds; the second re-added the same Jellyfin connection, which tore down the live client and aborted the home screen's in-flight fetches. The aborted pass was committed as loaded-empty, flashing 'no content available' until the follow-up load landed. Fix at the root instead of patching the sign-in window: - addJellyfinConnection now reuses the live client when the connection is unchanged (token, deviceId, URL set), matching the existing Plex refreshTokensForProfile behavior; material changes still recreate it. - Cancelled requests are classified end-to-end: the client's treat-as-empty helpers rethrow cancellations, and the aggregation fan-outs report cancelledServerIds alongside succeededServerIds. - A fetch pass in which zero servers succeeded is never authoritative: it keeps existing content instead of wiping it (also fixes the pre-existing blanking of home/sidebar on a totally failed refresh), stays in loading while disrupted (cancellation or binding in flight), and only commits loaded-empty on a settled failure.
This commit is contained in:
@@ -231,4 +231,65 @@ void main() {
|
||||
expect(requests.map((uri) => uri.host), ['primary.example.com', 'fallback.example.com', 'primary.example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
group('cancellation vs treat-as-empty', () {
|
||||
// The hub/next-up fetch helpers swallow per-endpoint failures into empty
|
||||
// lists so one broken endpoint doesn't sink a whole row. A *cancelled*
|
||||
// request is different: it means our own client was torn down mid-fetch
|
||||
// and says nothing about the server's content, so it must propagate —
|
||||
// otherwise a disrupted server counts as "succeeded with partial data"
|
||||
// and aborted sign-in fetches flash an empty home screen.
|
||||
MediaServerHttpException cancelled() =>
|
||||
MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing');
|
||||
|
||||
test('fetchContinueWatching propagates a cancelled NextUp sub-fetch', () async {
|
||||
final client = _withMock(
|
||||
MockClient((req) async {
|
||||
if (req.url.path == '/Shows/NextUp') throw cancelled();
|
||||
return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
await expectLater(
|
||||
client.fetchContinueWatching(),
|
||||
throwsA(isA<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchContinueWatching still treats a NextUp server error as empty', () async {
|
||||
final client = _withMock(
|
||||
MockClient((req) async {
|
||||
if (req.url.path == '/Shows/NextUp') return http.Response('Internal error', 500);
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'ep-1', 'Type': 'Episode', 'Name': 'Resume Me'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final items = await client.fetchContinueWatching();
|
||||
|
||||
expect(items.map((i) => i.id), ['ep-1']);
|
||||
});
|
||||
|
||||
test('fetchMoreHubItems propagates a cancellation and swallows server errors', () async {
|
||||
final cancelledClient = _withMock(MockClient((_) async => throw cancelled()));
|
||||
addTearDown(cancelledClient.close);
|
||||
await expectLater(
|
||||
cancelledClient.fetchMoreHubItems('home.nextup'),
|
||||
throwsA(isA<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
|
||||
);
|
||||
|
||||
final failingClient = _withMock(MockClient((_) async => http.Response('Internal error', 500)));
|
||||
addTearDown(failingClient.close);
|
||||
expect(await failingClient.fetchMoreHubItems('home.nextup'), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user