diff --git a/lib/services/trackers/anilist/anilist_auth_service.dart b/lib/services/trackers/anilist/anilist_auth_service.dart index f61df151..5865c835 100644 --- a/lib/services/trackers/anilist/anilist_auth_service.dart +++ b/lib/services/trackers/anilist/anilist_auth_service.dart @@ -1,6 +1,5 @@ -import 'dart:async'; - import '../oauth_proxy_client.dart'; +import '../oauth_proxy_auth_service.dart'; import 'anilist_session.dart'; /// AniList authentication via the Plezy relay's OAuth proxy. @@ -8,23 +7,12 @@ import 'anilist_session.dart'; /// We use AniList's authorization-code grant (not implicit), exchanged /// server-side so the device never sees the fragment. The proxy handles both /// state + client_secret; the device just gets the bearer token. -class AnilistAuthService { - final OAuthProxyClient _proxy; +class AnilistAuthService extends OAuthProxyAuthServiceBase { + AnilistAuthService({OAuthProxyClient? proxy}) : super(proxy: proxy); - AnilistAuthService({OAuthProxyClient? proxy}) : _proxy = proxy ?? OAuthProxyClient(); + @override + String get service => 'anilist'; - void dispose() => _proxy.dispose(); - - /// Drive the full flow. Returns null on user cancel. - Future authorize({ - required void Function(OAuthProxyStart) onCodeReady, - bool Function()? shouldCancel, - Future? onCancel, - }) async { - final start = await _proxy.start('anilist'); - onCodeReady(start); - final result = await _proxy.poll(start.session, shouldCancel: shouldCancel, onCancel: onCancel); - if (result == null) return null; - return AnilistSession.fromProxyResult(result); - } + @override + AnilistSession buildSession(OAuthProxyResult result) => AnilistSession.fromProxyResult(result); } diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index feac72ec..e678566c 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -7,6 +7,7 @@ import '../../../utils/app_logger.dart'; import '../../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../../utils/platform_http_client_io.dart' as platform; +import '../tracker_constants.dart'; import 'anilist_constants.dart'; import 'anilist_session.dart'; @@ -15,8 +16,6 @@ import 'anilist_session.dart'; /// No refresh endpoint — on 401 the session is terminal and /// [onSessionInvalidated] clears it so the user re-auths. class AnilistClient { - static const Duration _requestTimeout = Duration(seconds: 20); - final AnilistSession _session; final http.Client _http; final void Function() onSessionInvalidated; @@ -55,7 +54,7 @@ class AnilistClient { final body = json.encode({'query': query, 'variables': ?variables}); final sw = Stopwatch()..start(); - final res = await _http.post(uri, headers: headers, body: body).timeout(_requestTimeout); + final res = await _http.post(uri, headers: headers, body: body).timeout(TrackerConstants.requestTimeout); sw.stop(); appLogger.d('AniList POST ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); diff --git a/lib/services/trackers/future_coalescer.dart b/lib/services/trackers/future_coalescer.dart new file mode 100644 index 00000000..041aa6ab --- /dev/null +++ b/lib/services/trackers/future_coalescer.dart @@ -0,0 +1,15 @@ +class FutureCoalescer { + Future? _inFlight; + + Future run(Future Function() create) { + final existing = _inFlight; + if (existing != null) return existing; + + late final Future future; + future = create().whenComplete(() { + if (identical(_inFlight, future)) _inFlight = null; + }); + _inFlight = future; + return future; + } +} diff --git a/lib/services/trackers/mal/mal_auth_service.dart b/lib/services/trackers/mal/mal_auth_service.dart index e27a5bf7..81ac25c3 100644 --- a/lib/services/trackers/mal/mal_auth_service.dart +++ b/lib/services/trackers/mal/mal_auth_service.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; @@ -8,6 +7,8 @@ import '../../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../../utils/platform_http_client_io.dart' as platform; import '../oauth_proxy_client.dart'; +import '../oauth_proxy_auth_service.dart'; +import '../tracker_constants.dart'; import 'mal_constants.dart'; import 'mal_session.dart'; @@ -16,33 +17,25 @@ import 'mal_session.dart'; /// New sessions come from the Plezy relay's OAuth proxy (PKCE is server-side). /// Refreshes are direct public-client calls against MAL's token endpoint — /// no proxy needed because refresh requires no redirect. -class MalAuthService { - final OAuthProxyClient _proxy; +class MalAuthService extends OAuthProxyAuthServiceBase { final http.Client _http; MalAuthService({OAuthProxyClient? proxy, http.Client? httpClient}) - : _proxy = proxy ?? OAuthProxyClient(), - _http = httpClient ?? platform.createPlatformClient(); + : _http = httpClient ?? platform.createPlatformClient(), + super(proxy: proxy); + @override + String get service => 'mal'; + + @override + MalSession buildSession(OAuthProxyResult result) => MalSession.fromProxyResult(result); + + @override void dispose() { - _proxy.dispose(); + super.dispose(); _http.close(); } - /// Drive the full flow. Invokes [onCodeReady] with the QR URL once the - /// session is created, then long-polls for tokens. Returns null on cancel. - Future authorize({ - required void Function(OAuthProxyStart) onCodeReady, - bool Function()? shouldCancel, - Future? onCancel, - }) async { - final start = await _proxy.start('mal'); - onCodeReady(start); - final result = await _proxy.poll(start.session, shouldCancel: shouldCancel, onCancel: onCancel); - if (result == null) return null; - return MalSession.fromProxyResult(result); - } - Future refresh(MalSession current) async { final res = await _http .post( @@ -53,7 +46,7 @@ class MalAuthService { 'refresh_token': current.refreshToken, }, ) - .timeout(const Duration(seconds: 20)); + .timeout(TrackerConstants.requestTimeout); if (res.statusCode != 200) { appLogger.w('MAL: refresh failed (${res.statusCode}): ${res.body}'); diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index 32b54f30..1a357abc 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -7,6 +7,8 @@ import '../../../utils/app_logger.dart'; import '../../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../../utils/platform_http_client_io.dart' as platform; +import '../future_coalescer.dart'; +import '../tracker_constants.dart'; import 'mal_auth_service.dart'; import 'mal_constants.dart'; import 'mal_session.dart'; @@ -14,17 +16,15 @@ import 'mal_session.dart'; /// HTTP wrapper for the MAL REST API. /// /// Refreshes the access token 5 minutes before expiry or on 401. Concurrent -/// 401s are coalesced via [_refreshLock] (same pattern as `TraktClient`). +/// 401s are coalesced so only one refresh request is in flight. class MalClient { - static const Duration _requestTimeout = Duration(seconds: 20); - MalSession _session; final http.Client _http; final MalAuthService _auth; final void Function() onSessionInvalidated; final void Function(MalSession)? onSessionUpdated; - Future? _refreshLock; + final _refreshCoalescer = FutureCoalescer(); MalClient( MalSession session, { @@ -58,13 +58,7 @@ class MalClient { await _request('PATCH', '/anime/$animeId/my_list_status', formBody: fields); } - Future _refresh() { - final existing = _refreshLock; - if (existing != null) return existing; - final lock = _doRefresh(); - _refreshLock = lock; - return lock.whenComplete(() => _refreshLock = null); - } + Future _refresh() => _refreshCoalescer.run(_doRefresh); Future _doRefresh() async { try { @@ -143,7 +137,7 @@ class MalClient { 'PUT' => _http.put(uri, headers: headers, body: encoded), 'DELETE' => _http.delete(uri, headers: headers), _ => throw ArgumentError('Unsupported HTTP method: $method'), - }.timeout(_requestTimeout); + }.timeout(TrackerConstants.requestTimeout); sw.stop(); appLogger.d('MAL $method ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); return res; diff --git a/lib/services/trackers/oauth_proxy_auth_service.dart b/lib/services/trackers/oauth_proxy_auth_service.dart new file mode 100644 index 00000000..88d56a49 --- /dev/null +++ b/lib/services/trackers/oauth_proxy_auth_service.dart @@ -0,0 +1,25 @@ +import 'oauth_proxy_client.dart'; + +abstract class OAuthProxyAuthServiceBase { + final OAuthProxyClient proxy; + + OAuthProxyAuthServiceBase({OAuthProxyClient? proxy}) : proxy = proxy ?? OAuthProxyClient(); + + String get service; + + T buildSession(OAuthProxyResult result); + + void dispose() => proxy.dispose(); + + Future authorize({ + required void Function(OAuthProxyStart) onCodeReady, + bool Function()? shouldCancel, + Future? onCancel, + }) async { + final start = await proxy.start(service); + onCodeReady(start); + final result = await proxy.poll(start.session, shouldCancel: shouldCancel, onCancel: onCancel); + if (result == null) return null; + return buildSession(result); + } +} diff --git a/lib/services/trackers/oauth_proxy_client.dart b/lib/services/trackers/oauth_proxy_client.dart index 06b62105..243e71f2 100644 --- a/lib/services/trackers/oauth_proxy_client.dart +++ b/lib/services/trackers/oauth_proxy_client.dart @@ -8,6 +8,7 @@ import '../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../utils/platform_http_client_io.dart' as platform; import '../../watch_together/services/watch_together_peer_service.dart'; +import 'tracker_constants.dart'; /// Client for the Plezy relay's `/auth/*` OAuth proxy. /// @@ -34,7 +35,7 @@ class OAuthProxyClient { headers: {'Content-Type': 'application/json'}, body: json.encode({'service': service}), ) - .timeout(const Duration(seconds: 15)); + .timeout(TrackerConstants.authRequestTimeout); if (res.statusCode != 200) { throw OAuthProxyException('start failed: HTTP ${res.statusCode}: ${res.body}'); } @@ -63,12 +64,15 @@ class OAuthProxyClient { final Object? raced; try { - raced = await Future.any([_http.get(uri).timeout(const Duration(seconds: 65)), ?cancelFuture]); + raced = await Future.any([ + _http.get(uri).timeout(TrackerConstants.oauthProxyPollTimeout), + ?cancelFuture, + ]); } on TimeoutException { continue; } catch (e) { appLogger.d('oauth proxy: poll transient error', error: e); - await Future.delayed(const Duration(seconds: 2)); + await Future.delayed(TrackerConstants.oauthProxyRetryDelay); continue; } diff --git a/lib/services/trackers/simkl/simkl_auth_service.dart b/lib/services/trackers/simkl/simkl_auth_service.dart index 6a37548c..1d13ba3e 100644 --- a/lib/services/trackers/simkl/simkl_auth_service.dart +++ b/lib/services/trackers/simkl/simkl_auth_service.dart @@ -6,6 +6,7 @@ import '../../../models/trackers/device_code.dart'; import '../../../utils/app_logger.dart'; import '../device_code_auth_service.dart'; import '../oauth_proxy_client.dart'; +import '../tracker_constants.dart'; import 'simkl_constants.dart'; import 'simkl_session.dart'; @@ -23,7 +24,9 @@ class SimklAuthService extends DeviceCodeAuthServiceBase { final uri = Uri.parse(SimklConstants.pinUrl).replace( queryParameters: {'client_id': SimklConstants.clientId, 'redirect': '${OAuthProxyClient.baseUrl}/auth/done'}, ); - final res = await httpClient.get(uri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15)); + final res = await httpClient + .get(uri, headers: SimklConstants.headers()) + .timeout(TrackerConstants.authRequestTimeout); if (res.statusCode != 200) { throw DeviceCodeAuthFlowException('Simkl PIN request failed: HTTP ${res.statusCode}: ${res.body}'); } @@ -46,7 +49,9 @@ class SimklAuthService extends DeviceCodeAuthServiceBase { ).replace(queryParameters: {'client_id': SimklConstants.clientId}); final http.Response res; try { - res = await httpClient.get(pollUri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15)); + res = await httpClient + .get(pollUri, headers: SimklConstants.headers()) + .timeout(TrackerConstants.authRequestTimeout); } catch (e) { appLogger.d('Simkl device-code poll error (transient)', error: e); return const DevicePollPending(); diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index 157787ce..4b78656e 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -7,6 +7,7 @@ import '../../../utils/app_logger.dart'; import '../../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../../utils/platform_http_client_io.dart' as platform; +import '../tracker_constants.dart'; import 'simkl_constants.dart'; import 'simkl_session.dart'; @@ -16,8 +17,6 @@ import 'simkl_session.dart'; /// simkl.com/settings/apps). [onSessionInvalidated] clears the local session /// in that case. class SimklClient { - static const Duration _requestTimeout = Duration(seconds: 20); - final SimklSession session; final http.Client _http; final void Function() onSessionInvalidated; @@ -49,7 +48,7 @@ class SimklClient { 'GET' => _http.get(uri, headers: headers), 'POST' => _http.post(uri, headers: headers, body: encoded), _ => throw ArgumentError('Unsupported HTTP method: $method'), - }.timeout(_requestTimeout); + }.timeout(TrackerConstants.requestTimeout); sw.stop(); appLogger.d('Simkl $method ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); diff --git a/lib/services/trackers/tracker_constants.dart b/lib/services/trackers/tracker_constants.dart index d6b6e821..0a5fbf2f 100644 --- a/lib/services/trackers/tracker_constants.dart +++ b/lib/services/trackers/tracker_constants.dart @@ -1,10 +1,17 @@ -/// Shared constants for non-Trakt tracker integrations (MAL, AniList, Simkl). +/// Shared constants for tracker integrations. class TrackerConstants { TrackerConstants._(); /// Progress percent at which an episode/movie counts as watched and is /// pushed to each tracker. static const double watchedThresholdPercent = 80.0; + + static const Duration requestTimeout = Duration(seconds: 20); + static const Duration authRequestTimeout = Duration(seconds: 15); + static const Duration refreshTimeout = Duration(seconds: 15); + static const Duration revokeTimeout = Duration(seconds: 10); + static const Duration oauthProxyPollTimeout = Duration(seconds: 65); + static const Duration oauthProxyRetryDelay = Duration(seconds: 2); } /// Identifier used across the app to disambiguate per-service operations. diff --git a/lib/services/trakt/trakt_auth_service.dart b/lib/services/trakt/trakt_auth_service.dart index e9dd3d61..6c6fc0de 100644 --- a/lib/services/trakt/trakt_auth_service.dart +++ b/lib/services/trakt/trakt_auth_service.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import '../../models/trackers/device_code.dart'; import '../../utils/app_logger.dart'; import '../trackers/device_code_auth_service.dart'; +import '../trackers/tracker_constants.dart'; import 'trakt_constants.dart'; import 'trakt_session.dart'; @@ -21,7 +22,7 @@ class TraktAuthService extends DeviceCodeAuthServiceBase { final sw = Stopwatch()..start(); final res = await httpClient .post(uri, headers: TraktConstants.headers(), body: json.encode({'client_id': TraktConstants.clientId})) - .timeout(const Duration(seconds: 15)); + .timeout(TrackerConstants.authRequestTimeout); sw.stop(); appLogger.d('Trakt POST ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); @@ -57,7 +58,7 @@ class TraktAuthService extends DeviceCodeAuthServiceBase { 'client_secret': TraktConstants.clientSecret, }), ) - .timeout(const Duration(seconds: 15)); + .timeout(TrackerConstants.authRequestTimeout); appLogger.d('Trakt POST ${tokenUri.path} → ${res.statusCode}'); } catch (e) { appLogger.d('Trakt device-code poll error (transient)', error: e); diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index 49205d6f..e2323f6a 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -9,17 +9,16 @@ import '../../utils/app_logger.dart'; import '../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../utils/platform_http_client_io.dart' as platform; +import '../trackers/future_coalescer.dart'; +import '../trackers/tracker_constants.dart'; import 'trakt_constants.dart'; import 'trakt_session.dart'; /// HTTP wrapper for the Trakt REST API. /// /// Holds a [TraktSession] (refreshed in place on 401). Concurrent 401s are -/// coalesced via [_refreshLock] so we only hit `/oauth/token` once per refresh. +/// coalesced so we only hit `/oauth/token` once per refresh. class TraktClient { - static const Duration _requestTimeout = Duration(seconds: 20); - static const Duration _refreshTimeout = Duration(seconds: 15); - static const Duration _revokeTimeout = Duration(seconds: 10); static const Set _scrobbleAllowedStatuses = {200, 201, 409}; TraktSession _session; @@ -29,7 +28,7 @@ class TraktClient { /// uses this to clear the stored session and notify the UI. final void Function() onSessionInvalidated; - Future? _refreshLock; + final _refreshCoalescer = FutureCoalescer(); TraktClient(TraktSession session, {required this.onSessionInvalidated, http.Client? httpClient}) : _session = session, @@ -59,16 +58,9 @@ class TraktClient { Future removeFromHistory(TraktScrobbleRequest item) => _request('POST', '/sync/history/remove', body: item.toHistoryRemoveBody()); - /// Refresh the access token. Coalesces concurrent calls via [_refreshLock] so + /// Refresh the access token. Coalesces concurrent calls so /// duplicate POSTs don't race when multiple in-flight requests hit 401. - Future refresh() { - final existing = _refreshLock; - if (existing != null) return existing; - - final lock = _doRefresh(); - _refreshLock = lock; - return lock.whenComplete(() => _refreshLock = null); - } + Future refresh() => _refreshCoalescer.run(_doRefresh); Future _doRefresh() async { appLogger.d('Trakt: refreshing access token'); @@ -83,7 +75,7 @@ class TraktClient { 'grant_type': 'refresh_token', }), ) - .timeout(_refreshTimeout); + .timeout(TrackerConstants.refreshTimeout); if (res.statusCode == 200) { final body = json.decode(res.body) as Map; @@ -109,7 +101,7 @@ class TraktClient { 'client_secret': TraktConstants.clientSecret, }), ) - .timeout(_revokeTimeout); + .timeout(TrackerConstants.revokeTimeout); } catch (e) { appLogger.d('Trakt: revoke failed (non-fatal)', error: e); } @@ -165,7 +157,7 @@ class TraktClient { 'PUT' => _http.put(uri, headers: headers, body: encoded), 'DELETE' => _http.delete(uri, headers: headers), _ => throw ArgumentError('Unsupported HTTP method: $method'), - }.timeout(_requestTimeout); + }.timeout(TrackerConstants.requestTimeout); sw.stop(); appLogger.d('Trakt $method ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); diff --git a/test/services/trackers/future_coalescer_test.dart b/test/services/trackers/future_coalescer_test.dart new file mode 100644 index 00000000..b63b7c3f --- /dev/null +++ b/test/services/trackers/future_coalescer_test.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/trackers/future_coalescer.dart'; + +void main() { + test('FutureCoalescer shares one in-flight future', () async { + final coalescer = FutureCoalescer(); + final completer = Completer(); + var calls = 0; + + Future create() { + calls++; + return completer.future; + } + + final first = coalescer.run(create); + final second = coalescer.run(create); + + expect(identical(first, second), isTrue); + expect(calls, 1); + + completer.complete(42); + expect(await first, 42); + }); + + test('FutureCoalescer allows a new future after completion', () async { + final coalescer = FutureCoalescer(); + var next = 0; + + final first = await coalescer.run(() async => ++next); + final second = await coalescer.run(() async => ++next); + + expect(first, 1); + expect(second, 2); + }); +}