refactor: share tracker auth plumbing

This commit is contained in:
edde746
2026-05-02 02:02:50 +02:00
parent 50bc245677
commit b42ee26840
13 changed files with 142 additions and 83 deletions
@@ -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;
}
}
+14 -21
View File
@@ -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}');
+6 -12
View File
@@ -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)');
+8 -1
View File
@@ -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.
+3 -2
View File
@@ -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
View File
@@ -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);
});
}