Connects MDBList through its OAuth device-code grant, registered as a Device Code app so no client secret or redirect URI ships in the binary and TV, mobile and desktop all use the same flow. MDBList omits `verification_uri_complete`, but its device page seeds the code field from a `user_code` query parameter and the sign-in redirect preserves the query string, so the activation link is built locally and the dialog's open button lands on a filled-in form instead of an empty one. A server-supplied complete URL still wins if one ever appears. Poll state is read from the response body rather than the status code: `authorization_pending` and `slow_down` both arrive as HTTP 400, and a missing grant answers 404 `device_not_found`. Writes go out as real-time `/scrobble/*` reports plus `/sync/watched` for the marks that never pass through the player, with ratings on `/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block has no `tvdb` field, so a TVDB-only item is skipped rather than written under an empty id block.
162 lines
6.1 KiB
Dart
162 lines
6.1 KiB
Dart
import 'oauth_proxy_client.dart';
|
|
import 'tracker_constants.dart';
|
|
import 'tracker_exceptions.dart';
|
|
import 'tracker_session_utils.dart';
|
|
|
|
class TrackerSession {
|
|
final String accessToken;
|
|
final String? refreshToken;
|
|
final int? expiresAt;
|
|
final String? username;
|
|
final int createdAt;
|
|
final String? scope;
|
|
|
|
const TrackerSession({
|
|
required this.accessToken,
|
|
required this.createdAt,
|
|
this.refreshToken,
|
|
this.expiresAt,
|
|
this.username,
|
|
this.scope,
|
|
});
|
|
|
|
bool get isExpired => expiresAt != null && isTrackerTokenExpired(expiresAt!);
|
|
bool get needsRefresh => expiresAt != null && trackerTokenNeedsRefresh(expiresAt!);
|
|
|
|
String requireRefreshToken(TrackerService service) => _requireRefreshToken(service, refreshToken);
|
|
|
|
TrackerSession copyWith({
|
|
String? accessToken,
|
|
String? refreshToken,
|
|
int? expiresAt,
|
|
String? username,
|
|
int? createdAt,
|
|
String? scope,
|
|
}) {
|
|
return TrackerSession(
|
|
accessToken: accessToken ?? this.accessToken,
|
|
refreshToken: refreshToken ?? this.refreshToken,
|
|
expiresAt: expiresAt ?? this.expiresAt,
|
|
username: username ?? this.username,
|
|
createdAt: createdAt ?? this.createdAt,
|
|
scope: scope ?? this.scope,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'access_token': accessToken,
|
|
'refresh_token': refreshToken,
|
|
'expires_at': expiresAt,
|
|
'username': username,
|
|
'scope': scope,
|
|
'created_at': createdAt,
|
|
};
|
|
|
|
String encode() => encodeTrackerSessionJson(toJson());
|
|
|
|
factory TrackerSession.fromJson(Map<String, dynamic> json, {TrackerService? service}) {
|
|
final session = TrackerSession(
|
|
accessToken: json['access_token'] as String,
|
|
refreshToken: json['refresh_token'] as String?,
|
|
expiresAt: (json['expires_at'] as num?)?.toInt(),
|
|
username: json['username'] as String?,
|
|
scope: json['scope'] as String? ?? (service == TrackerService.trakt ? 'public' : null),
|
|
createdAt: (json['created_at'] as num).toInt(),
|
|
);
|
|
// When decoding a persisted blob we know the service, so re-impose the
|
|
// per-service invariants the old json_serializable decoders guaranteed.
|
|
// A corrupt/truncated blob then throws (and TrackerAccountStore.load's
|
|
// catch falls back to a clean re-auth) rather than loading a broken,
|
|
// never-expiring session.
|
|
if (service != null) session._validatePersisted(service);
|
|
return session;
|
|
}
|
|
|
|
void _validatePersisted(TrackerService service) {
|
|
void requireExpiry() {
|
|
if (expiresAt == null) {
|
|
throw TrackerAuthException(service: service, message: 'Corrupt persisted session', isPermanent: true);
|
|
}
|
|
}
|
|
|
|
switch (service) {
|
|
case TrackerService.mal:
|
|
case TrackerService.trakt:
|
|
case TrackerService.mdblist:
|
|
_validateRefreshToken(service, refreshToken);
|
|
requireExpiry();
|
|
case TrackerService.anilist:
|
|
requireExpiry();
|
|
case TrackerService.simkl:
|
|
return;
|
|
}
|
|
}
|
|
|
|
factory TrackerSession.fromOAuthProxyResult(TrackerService service, OAuthProxyResult result) {
|
|
final createdAt = trackerSessionNowEpochSeconds();
|
|
return switch (service) {
|
|
TrackerService.anilist => TrackerSession(
|
|
accessToken: result.accessToken,
|
|
expiresAt: createdAt + (result.expiresIn ?? 365 * 24 * 60 * 60),
|
|
createdAt: createdAt,
|
|
),
|
|
TrackerService.mal => TrackerSession(
|
|
accessToken: result.accessToken,
|
|
refreshToken: _requireRefreshToken(service, result.refreshToken),
|
|
expiresAt: createdAt + (result.expiresIn ?? 31 * 24 * 60 * 60),
|
|
createdAt: createdAt,
|
|
),
|
|
_ => throw ArgumentError('OAuth proxy sessions are not supported for ${service.name}'),
|
|
};
|
|
}
|
|
|
|
factory TrackerSession.fromTokenResponse(TrackerService service, Map<String, dynamic> json) {
|
|
final createdAt = (json['created_at'] as num?)?.toInt() ?? trackerSessionNowEpochSeconds();
|
|
return switch (service) {
|
|
TrackerService.mal => TrackerSession(
|
|
accessToken: json['access_token'] as String,
|
|
refreshToken: _requireRefreshToken(service, json['refresh_token'] as String?),
|
|
expiresAt: createdAt + (json['expires_in'] as num).toInt(),
|
|
createdAt: createdAt,
|
|
),
|
|
TrackerService.simkl => TrackerSession(accessToken: json['access_token'] as String, createdAt: createdAt),
|
|
// MDBList issues a 30-day access token plus a refresh token; the scope
|
|
// is always `write`, its only offering.
|
|
TrackerService.mdblist => TrackerSession(
|
|
accessToken: json['access_token'] as String,
|
|
refreshToken: _requireRefreshToken(service, json['refresh_token'] as String?),
|
|
expiresAt: createdAt + (json['expires_in'] as num).toInt(),
|
|
scope: json['scope'] as String? ?? 'write',
|
|
createdAt: createdAt,
|
|
),
|
|
TrackerService.trakt => TrackerSession(
|
|
accessToken: json['access_token'] as String,
|
|
refreshToken: _requireRefreshToken(service, json['refresh_token'] as String?),
|
|
expiresAt: createdAt + (json['expires_in'] as num).toInt(),
|
|
scope: json['scope'] as String? ?? 'public',
|
|
createdAt: createdAt,
|
|
),
|
|
_ => throw ArgumentError('Token-response sessions are not supported for ${service.name}'),
|
|
};
|
|
}
|
|
|
|
static void _validateRefreshToken(TrackerService service, String? token) {
|
|
// Reject empty as well as null: a blank refresh token can never refresh, so
|
|
// failing fast (at connect, or as a clean re-auth on load) is more correct
|
|
// than persisting a session that is silently doomed at the next expiry. The
|
|
// connect-time factories route through here too, so new sessions can never
|
|
// reach the persisted empty-token state the old decoders tolerated.
|
|
if (token == null || token.isEmpty) {
|
|
throw TrackerAuthException(service: service, message: 'Missing refresh token', isPermanent: true);
|
|
}
|
|
}
|
|
|
|
static String _requireRefreshToken(TrackerService service, String? token) {
|
|
_validateRefreshToken(service, token);
|
|
return token!;
|
|
}
|
|
|
|
static TrackerSession decode(String raw, {TrackerService? service}) =>
|
|
decodeTrackerSessionJson(raw, (json) => TrackerSession.fromJson(json, service: service));
|
|
}
|