Trakt was the one service outside the tracker abstraction. TraktScrobbleService re-implemented the whole playback lifecycle beside TrackerCoordinator, and TraktSyncService pushed watched state from its own WatchStateNotifier subscription, so the player called two objects at every lifecycle point and one watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker like Simkl; the duplicated player call sites collapse to one each, and Trakt shares the coordinator's ID resolver instead of re-fetching show ids every episode. Capabilities are split so a tracker declares what it is rather than being special-cased: ScrobblePolicy carries each service's own resend/seek rules, EpisodeHistoryTracker names the remote row a per-item history write targets, and SeriesProgressTracker covers one-counter-per-series services. Writes from all four trackers go through a shared TrackerWriteQueue, generalised from the Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one object owns the active session per profile. Two failure paths found while consolidating are fixed here too. The queue's retries only ran on profile bind, connect and app foreground, so a network blip mid-session left queued watches waiting for the next foreground. OfflineModeProvider now notifies on connectivity changes, not just offline-state or WiFi-flag changes, and main.dart flushes the queue when the network returns. The queue also counted every failure toward the five attempts that permanently drop an item, so a rate limit or a service having a bad hour could discard a pending watch - the loss the queue exists to prevent. Only an answer about the write itself now spends an attempt: 4xx counts, while rate limits, 5xx, recoverable token-refresh failures and requests that never arrived do not. A back-off answer also defers that service for the rest of the flush, so a queue holding many rows does not fire all of them at a service that just asked for quiet.
285 lines
10 KiB
Dart
285 lines
10 KiB
Dart
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/media/ids.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:plezy/connection/connection.dart';
|
|
import 'package:plezy/providers/offline_mode_provider.dart';
|
|
import 'package:plezy/services/jellyfin_client.dart';
|
|
import 'package:plezy/services/multi_server_manager.dart';
|
|
import 'package:plezy/services/plex_auth_service.dart';
|
|
|
|
import '../test_helpers/multi_server_fixtures.dart';
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
void main() {
|
|
// OfflineModeProvider depends on a MultiServerManager. We instantiate one with
|
|
// no connected servers — this exercises only the in-memory bookkeeping (id
|
|
// maps + status stream) and never opens an HTTP socket. Network paths
|
|
// (initialize/refresh's connectivity_plus call) are skipped: the
|
|
// MissingPluginException in tests is already swallowed by the provider's
|
|
// try/catch, so we don't drive `initialize()` here.
|
|
setUp(resetSharedPreferencesForTest);
|
|
|
|
group('OfflineModeProvider', () {
|
|
test('with empty manager: hasServerConnection=false but isOffline stays false during warmup', () {
|
|
final manager = MultiServerManager();
|
|
final p = OfflineModeProvider(manager);
|
|
|
|
// Until [MultiServerManager] emits its first status snapshot, we don't
|
|
// actually know whether the binder will connect anything — treating an
|
|
// empty manager as offline causes the cold-start UI to flash the
|
|
// offline state for the few hundred ms it takes to come up. Stay
|
|
// optimistic.
|
|
expect(p.hasNetworkConnection, isTrue);
|
|
expect(p.hasServerConnection, isFalse);
|
|
expect(p.isOffline, isFalse);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('reads online server IDs from the manager at construction', () {
|
|
final manager = MultiServerManager();
|
|
manager.updateServerStatus(ServerId('srv-1'), true);
|
|
final p = OfflineModeProvider(manager);
|
|
|
|
expect(p.hasServerConnection, isTrue);
|
|
// Network is assumed up by default; both up → not offline.
|
|
expect(p.isOffline, isFalse);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('all servers offline at construction → still warmup-optimistic until status emits', () {
|
|
// updateServerStatus pushes to a broadcast controller — the provider
|
|
// hasn't subscribed yet, so it never sees these events. After
|
|
// construction `onlineServerIds` is empty (the same shape as a
|
|
// fresh-cold-start manager), so we stay optimistic until the
|
|
// provider's own listener catches an emission.
|
|
final manager = MultiServerManager();
|
|
manager.updateServerStatus(ServerId('srv-1'), false);
|
|
manager.updateServerStatus(ServerId('srv-2'), false);
|
|
final p = OfflineModeProvider(manager);
|
|
|
|
expect(p.hasServerConnection, isFalse);
|
|
expect(p.isOffline, isFalse);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('dispose marks provider as disposed; later notifies are no-ops', () {
|
|
final manager = MultiServerManager();
|
|
final p = OfflineModeProvider(manager);
|
|
|
|
p.dispose();
|
|
// After dispose, the disposable mixin guards against post-dispose notify.
|
|
// We can't call private safeNotifyListeners, but `isDisposed` reflects state.
|
|
expect(p.isDisposed, isTrue);
|
|
|
|
manager.dispose();
|
|
});
|
|
|
|
test('warmup skipped when manager already has an online server at construction', () {
|
|
// If the manager already has an online server when the provider is
|
|
// built, we have ground truth — no need for the warmup window.
|
|
// hasServerConnection reflects the manager's state and isOffline
|
|
// is correctly false (network up + server up).
|
|
final manager = MultiServerManager();
|
|
manager.updateServerStatus(ServerId('srv'), true);
|
|
final p = OfflineModeProvider(manager);
|
|
|
|
expect(p.hasServerConnection, isTrue);
|
|
expect(p.isOffline, isFalse);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('auth-error-only visible servers do not collapse to generic offline', () async {
|
|
final manager = MultiServerManager();
|
|
final client = JellyfinClient.forTesting(
|
|
connection: _jellyfinConnection(),
|
|
httpClient: MockClient((_) async => http.Response('', 401)),
|
|
);
|
|
manager.debugRegisterJellyfinClientForTesting(client, online: false);
|
|
final multi = testMultiServerProvider(manager);
|
|
final p = OfflineModeProvider(manager, multiServerProvider: multi);
|
|
await p.initialize();
|
|
|
|
manager.debugMarkAuthErrorForTesting(ServerId('jf-machine'));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(multi.authErrorServerIds, contains('jf-machine'));
|
|
expect(p.isOffline, isFalse);
|
|
|
|
p.dispose();
|
|
multi.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('expected but unreachable visible servers enter offline without live clients', () async {
|
|
final manager = MultiServerManager();
|
|
final multi = testMultiServerProvider(manager);
|
|
final p = OfflineModeProvider(manager, multiServerProvider: multi);
|
|
await p.initialize();
|
|
manager.updateServerStatus(ServerId('plex-server'), false);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(p.isOffline, isFalse);
|
|
|
|
var notifications = 0;
|
|
p.addListener(() => notifications++);
|
|
|
|
multi.setExpectedVisibleServerIds({'plex-server'});
|
|
multi.setVisibleServerIds(<String>{});
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(p.isOffline, isTrue);
|
|
expect(notifications, 1);
|
|
|
|
p.dispose();
|
|
multi.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('expected but unreachable profile servers enter offline once visibility settles', () async {
|
|
final manager = MultiServerManager();
|
|
final multi = testMultiServerProvider(manager);
|
|
final p = OfflineModeProvider(manager, multiServerProvider: multi);
|
|
|
|
expect(p.isOffline, isFalse);
|
|
|
|
var notifications = 0;
|
|
p.addListener(() => notifications++);
|
|
|
|
multi.setExpectedVisibleServerIds({'jf-machine'});
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(p.isOffline, isFalse);
|
|
expect(notifications, 0);
|
|
|
|
multi.setVisibleServerIds(<String>{});
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(p.isOffline, isTrue);
|
|
expect(notifications, 1);
|
|
|
|
p.dispose();
|
|
multi.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('Plex auth errors without live clients stay out of generic offline', () async {
|
|
final manager = MultiServerManager();
|
|
final multi = testMultiServerProvider(manager);
|
|
final p = OfflineModeProvider(manager, multiServerProvider: multi);
|
|
await p.initialize();
|
|
|
|
multi.setExpectedVisibleServerIds({'plex-server'});
|
|
manager.markPlexConnectionAuthError(_plexConnection());
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(multi.authErrorServerIds, ['plex-server']);
|
|
expect(multi.authErrorServers.single.displayName, 'Plex');
|
|
expect(p.isOffline, isFalse);
|
|
|
|
p.dispose();
|
|
multi.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
group('connectivity transitions', () {
|
|
test('regaining any network notifies even while servers stay unreachable', () async {
|
|
final manager = MultiServerManager();
|
|
final multi = testMultiServerProvider(manager);
|
|
final p = OfflineModeProvider(manager, multiServerProvider: multi);
|
|
// Settle visibility with nothing reachable, so offline is owned by
|
|
// `noServerConnection` rather than the network flag or startup warmup.
|
|
multi.setExpectedVisibleServerIds({'plex-server'});
|
|
multi.setVisibleServerIds({'plex-server'});
|
|
await Future<void>.delayed(Duration.zero);
|
|
p.applyConnectivityResults(const [ConnectivityResult.none]);
|
|
expect(p.hasNetworkConnection, isFalse);
|
|
expect(p.isOffline, isTrue);
|
|
|
|
var notifications = 0;
|
|
p.addListener(() => notifications++);
|
|
|
|
// Cellular comes back but no server is reachable, so neither the composite
|
|
// offline state nor the WiFi/Ethernet flag moves. Consumers that only need
|
|
// the internet — queued tracker history writes — still have to hear it.
|
|
p.applyConnectivityResults(const [ConnectivityResult.mobile]);
|
|
|
|
expect(p.hasNetworkConnection, isTrue);
|
|
expect(p.hasWifiOrEthernet, isFalse);
|
|
expect(p.isOffline, isTrue, reason: 'servers are still unreachable');
|
|
expect(notifications, 1);
|
|
|
|
p.dispose();
|
|
multi.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('an unchanged connectivity snapshot notifies nobody', () async {
|
|
final manager = MultiServerManager();
|
|
final p = OfflineModeProvider(manager);
|
|
p.applyConnectivityResults(const [ConnectivityResult.wifi]);
|
|
|
|
var notifications = 0;
|
|
p.addListener(() => notifications++);
|
|
p.applyConnectivityResults(const [ConnectivityResult.wifi]);
|
|
|
|
expect(notifications, isZero);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
PlexAccountConnection _plexConnection() {
|
|
return PlexAccountConnection(
|
|
id: 'plex-account',
|
|
accountToken: 'account-token',
|
|
clientIdentifier: 'client-id',
|
|
accountLabel: 'Plex Account',
|
|
servers: [
|
|
PlexServer(
|
|
name: 'Plex',
|
|
clientIdentifier: 'plex-server',
|
|
accessToken: 'server-token',
|
|
connections: [
|
|
PlexConnection(
|
|
protocol: 'https',
|
|
address: 'plex.example',
|
|
port: 32400,
|
|
uri: 'https://plex.example:32400',
|
|
local: true,
|
|
relay: false,
|
|
ipv6: false,
|
|
),
|
|
],
|
|
owned: true,
|
|
),
|
|
],
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
|
);
|
|
}
|
|
|
|
JellyfinConnection _jellyfinConnection() {
|
|
return JellyfinConnection(
|
|
id: 'jf-machine/user-a',
|
|
baseUrl: 'https://jellyfin.example',
|
|
serverName: 'Jellyfin',
|
|
serverMachineId: 'jf-machine',
|
|
userId: 'user-a',
|
|
userName: 'User A',
|
|
accessToken: 'token',
|
|
deviceId: 'device',
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
|
);
|
|
}
|