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:
edde746
2026-07-04 23:44:38 +02:00
parent c8ca8a7875
commit 87aea49ab6
12 changed files with 648 additions and 30 deletions
@@ -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);
});
});
}