refactor: share tracker auth plumbing
This commit is contained in:
@@ -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<AnilistSession> {
|
||||
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<AnilistSession?> authorize({
|
||||
required void Function(OAuthProxyStart) onCodeReady,
|
||||
bool Function()? shouldCancel,
|
||||
Future<void>? 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);
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class FutureCoalescer<T> {
|
||||
Future<T>? _inFlight;
|
||||
|
||||
Future<T> run(Future<T> Function() create) {
|
||||
final existing = _inFlight;
|
||||
if (existing != null) return existing;
|
||||
|
||||
late final Future<T> future;
|
||||
future = create().whenComplete(() {
|
||||
if (identical(_inFlight, future)) _inFlight = null;
|
||||
});
|
||||
_inFlight = future;
|
||||
return future;
|
||||
}
|
||||
}
|
||||
@@ -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<MalSession> {
|
||||
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<MalSession?> authorize({
|
||||
required void Function(OAuthProxyStart) onCodeReady,
|
||||
bool Function()? shouldCancel,
|
||||
Future<void>? 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<MalSession> 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}');
|
||||
|
||||
@@ -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<MalSession>? _refreshLock;
|
||||
final _refreshCoalescer = FutureCoalescer<MalSession>();
|
||||
|
||||
MalClient(
|
||||
MalSession session, {
|
||||
@@ -58,13 +58,7 @@ class MalClient {
|
||||
await _request('PATCH', '/anime/$animeId/my_list_status', formBody: fields);
|
||||
}
|
||||
|
||||
Future<MalSession> _refresh() {
|
||||
final existing = _refreshLock;
|
||||
if (existing != null) return existing;
|
||||
final lock = _doRefresh();
|
||||
_refreshLock = lock;
|
||||
return lock.whenComplete(() => _refreshLock = null);
|
||||
}
|
||||
Future<MalSession> _refresh() => _refreshCoalescer.run(_doRefresh);
|
||||
|
||||
Future<MalSession> _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;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'oauth_proxy_client.dart';
|
||||
|
||||
abstract class OAuthProxyAuthServiceBase<T> {
|
||||
final OAuthProxyClient proxy;
|
||||
|
||||
OAuthProxyAuthServiceBase({OAuthProxyClient? proxy}) : proxy = proxy ?? OAuthProxyClient();
|
||||
|
||||
String get service;
|
||||
|
||||
T buildSession(OAuthProxyResult result);
|
||||
|
||||
void dispose() => proxy.dispose();
|
||||
|
||||
Future<T?> authorize({
|
||||
required void Function(OAuthProxyStart) onCodeReady,
|
||||
bool Function()? shouldCancel,
|
||||
Future<void>? 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Object?>([_http.get(uri).timeout(const Duration(seconds: 65)), ?cancelFuture]);
|
||||
raced = await Future.any<Object?>([
|
||||
_http.get(uri).timeout(TrackerConstants.oauthProxyPollTimeout),
|
||||
?cancelFuture,
|
||||
]);
|
||||
} on TimeoutException {
|
||||
continue;
|
||||
} catch (e) {
|
||||
appLogger.d('oauth proxy: poll transient error', error: e);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
await Future<void>.delayed(TrackerConstants.oauthProxyRetryDelay);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SimklSession> {
|
||||
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<SimklSession> {
|
||||
).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();
|
||||
|
||||
@@ -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)');
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<TraktSession> {
|
||||
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<TraktSession> {
|
||||
'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);
|
||||
|
||||
@@ -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<int> _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<TraktSession>? _refreshLock;
|
||||
final _refreshCoalescer = FutureCoalescer<TraktSession>();
|
||||
|
||||
TraktClient(TraktSession session, {required this.onSessionInvalidated, http.Client? httpClient})
|
||||
: _session = session,
|
||||
@@ -59,16 +58,9 @@ class TraktClient {
|
||||
Future<void> 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<TraktSession> refresh() {
|
||||
final existing = _refreshLock;
|
||||
if (existing != null) return existing;
|
||||
|
||||
final lock = _doRefresh();
|
||||
_refreshLock = lock;
|
||||
return lock.whenComplete(() => _refreshLock = null);
|
||||
}
|
||||
Future<TraktSession> refresh() => _refreshCoalescer.run(_doRefresh);
|
||||
|
||||
Future<TraktSession> _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<String, dynamic>;
|
||||
@@ -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)');
|
||||
|
||||
@@ -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<int>();
|
||||
final completer = Completer<int>();
|
||||
var calls = 0;
|
||||
|
||||
Future<int> 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<int>();
|
||||
var next = 0;
|
||||
|
||||
final first = await coalescer.run(() async => ++next);
|
||||
final second = await coalescer.run(() async => ++next);
|
||||
|
||||
expect(first, 1);
|
||||
expect(second, 2);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user