feat(mdblist): sync watched history, scrobbles and ratings with MDBList
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.
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
/// Losing it orphans every one of those ciphertexts permanently.
|
||||
/// * tracker sessions — `TrackerAccountStore` persists `TrackerSession.encode()`
|
||||
/// verbatim, so raw OAuth `access_token`/`refresh_token` pairs for MAL,
|
||||
/// AniList, Simkl and Trakt live here in plaintext.
|
||||
/// AniList, Simkl, Trakt and MDBList live here in plaintext.
|
||||
/// * Seerr sessions — `SeerrSessionStore` persists a raw `connect.sid` cookie
|
||||
/// alongside a vault-protected password.
|
||||
/// * [legacyPlexTokenPref] — the pre-connection-registry Plex token slot. It is
|
||||
@@ -44,6 +44,7 @@ const List<String> trackerSessionBaseKeys = <String>[
|
||||
'anilist_session',
|
||||
'simkl_session',
|
||||
'trakt_session',
|
||||
'mdblist_session',
|
||||
];
|
||||
|
||||
/// Unscoped base key used by `SeerrSessionStore`.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../models/trackers/device_code.dart';
|
||||
import '../../../utils/abortable_http_request.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../device_code_auth_service.dart';
|
||||
import '../tracker_constants.dart';
|
||||
import '../tracker_exceptions.dart';
|
||||
import '../tracker_session.dart';
|
||||
import 'mdblist_constants.dart';
|
||||
|
||||
/// MDBList OAuth Device Authorization Grant (RFC 8628).
|
||||
///
|
||||
/// The user opens `mdblist.com/oauth/device/` on any second device and
|
||||
/// approves the shown code; the app polls the token endpoint meanwhile. No
|
||||
/// redirect URI, no local listener and no client secret are involved, so the
|
||||
/// same flow works identically on TV, mobile and desktop.
|
||||
///
|
||||
/// Unlike Trakt, MDBList reports poll state in the JSON body rather than
|
||||
/// through distinct status codes — `authorization_pending` and `slow_down`
|
||||
/// both arrive as HTTP 400 — so [probe] switches on `error`, not the status.
|
||||
class MdblistAuthService extends DeviceCodeAuthServiceBase {
|
||||
/// MDBList returns these for a terminally-invalid grant (revoked or expired
|
||||
/// refresh token); anything else (5xx, network) is transient and must not
|
||||
/// log the user out.
|
||||
static const Set<int> _permanentRefreshFailureStatuses = {400, 401, 403};
|
||||
|
||||
/// Fallbacks for a response that omits them. MDBList currently answers with
|
||||
/// a 30-minute window and a 5-second interval.
|
||||
static const int _defaultExpiresIn = 1800;
|
||||
static const int _defaultInterval = 5;
|
||||
|
||||
MdblistAuthService({super.httpClient});
|
||||
|
||||
@override
|
||||
Future<DeviceCode> createDeviceCode() async {
|
||||
final uri = Uri.parse(MdblistConstants.deviceAuthorizationUrl);
|
||||
final res = await sendAbortableHttpRequest(
|
||||
httpClient,
|
||||
'POST',
|
||||
uri,
|
||||
body: {'client_id': MdblistConstants.clientId, 'scope': MdblistConstants.scope},
|
||||
timeout: TrackerConstants.authRequestTimeout,
|
||||
operation: 'MDBList device code request',
|
||||
);
|
||||
appLogger.d('MDBList POST ${uri.path} → ${res.statusCode}');
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw DeviceCodeAuthFlowException('MDBList device code request failed: HTTP ${res.statusCode}');
|
||||
}
|
||||
|
||||
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||
final userCode = body['user_code'] as String;
|
||||
final verificationUrl = body['verification_uri'] as String? ?? MdblistConstants.verificationUrl;
|
||||
return DeviceCode(
|
||||
deviceCode: body['device_code'] as String,
|
||||
userCode: userCode,
|
||||
verificationUrl: verificationUrl,
|
||||
// MDBList omits `verification_uri_complete`, but its device page seeds
|
||||
// the form from `?user_code=`, so build the prefilled link ourselves and
|
||||
// still prefer a server-supplied one if it ever starts sending it.
|
||||
verificationUrlComplete:
|
||||
body['verification_uri_complete'] as String? ??
|
||||
MdblistConstants.verificationUrlFor(userCode, verificationUrl: verificationUrl),
|
||||
expiresIn: (body['expires_in'] as num?)?.toInt() ?? _defaultExpiresIn,
|
||||
interval: (body['interval'] as num?)?.toInt() ?? _defaultInterval,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DevicePollEvent> probe(DeviceCode code) async {
|
||||
final tokenUri = Uri.parse(MdblistConstants.tokenUrl);
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await sendAbortableHttpRequest(
|
||||
httpClient,
|
||||
'POST',
|
||||
tokenUri,
|
||||
body: {
|
||||
'grant_type': MdblistConstants.deviceCodeGrantType,
|
||||
'device_code': code.deviceCode,
|
||||
'client_id': MdblistConstants.clientId,
|
||||
},
|
||||
timeout: TrackerConstants.authRequestTimeout,
|
||||
operation: 'MDBList device token poll',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.d('MDBList device-code poll error (transient)', error: e);
|
||||
return const DevicePollPending();
|
||||
}
|
||||
|
||||
final body = _decodeBody(res.body);
|
||||
if (res.statusCode == 200 && body['access_token'] != null) {
|
||||
return DevicePollSuccess(body);
|
||||
}
|
||||
|
||||
return switch (body['error']) {
|
||||
'authorization_pending' => const DevicePollPending(),
|
||||
'slow_down' => const DevicePollSlowDown(),
|
||||
'access_denied' => const DevicePollDenied(),
|
||||
// `device_not_found` is MDBList's own code for a device grant that no
|
||||
// longer exists, which is terminal in the same way as an expiry.
|
||||
'expired_token' || 'device_not_found' => const DevicePollExpired(),
|
||||
final error => _unexpected(error, res.statusCode),
|
||||
};
|
||||
}
|
||||
|
||||
/// Keep polling on anything unrecognised: the deadline in the poll loop
|
||||
/// still bounds the flow, and treating an unknown code as terminal would
|
||||
/// abandon an authorization the user may be about to complete.
|
||||
static DevicePollEvent _unexpected(Object? error, int statusCode) {
|
||||
appLogger.w('MDBList device-code unexpected response (HTTP $statusCode, error=$error)');
|
||||
return const DevicePollPending();
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _decodeBody(String body) {
|
||||
try {
|
||||
final decoded = json.decode(body);
|
||||
return decoded is Map<String, dynamic> ? decoded : const {};
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
TrackerSession buildSession(Map<String, dynamic> tokenResponse) =>
|
||||
TrackerSession.fromTokenResponse(TrackerService.mdblist, tokenResponse);
|
||||
|
||||
/// Exchange the refresh token for a fresh access token. Public client, so
|
||||
/// no secret rides along — only the client ID.
|
||||
Future<TrackerSession> refresh(TrackerSession current) async {
|
||||
final res = await sendAbortableHttpRequest(
|
||||
httpClient,
|
||||
'POST',
|
||||
Uri.parse(MdblistConstants.tokenUrl),
|
||||
body: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': current.requireRefreshToken(TrackerService.mdblist),
|
||||
'client_id': MdblistConstants.clientId,
|
||||
},
|
||||
timeout: TrackerConstants.refreshTimeout,
|
||||
operation: 'MDBList token refresh',
|
||||
);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
appLogger.w('MDBList: refresh failed (HTTP ${res.statusCode})');
|
||||
throw TrackerAuthException(
|
||||
service: TrackerService.mdblist,
|
||||
message: 'Refresh failed: HTTP ${res.statusCode}',
|
||||
statusCode: res.statusCode,
|
||||
isPermanent: _permanentRefreshFailureStatuses.contains(res.statusCode),
|
||||
);
|
||||
}
|
||||
|
||||
final fresh = TrackerSession.fromTokenResponse(
|
||||
TrackerService.mdblist,
|
||||
json.decode(res.body) as Map<String, dynamic>,
|
||||
);
|
||||
return fresh.copyWith(username: current.username);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../future_coalescer.dart';
|
||||
import '../tracker.dart';
|
||||
import '../tracker_constants.dart';
|
||||
import '../tracker_exceptions.dart';
|
||||
import '../tracker_http_client.dart';
|
||||
import '../tracker_session.dart';
|
||||
import 'mdblist_auth_service.dart';
|
||||
import 'mdblist_constants.dart';
|
||||
|
||||
/// HTTP wrapper for the MDBList REST API.
|
||||
///
|
||||
/// Access tokens live 30 days and rotate through the refresh grant, so this
|
||||
/// mirrors the MAL/Trakt shape: refresh shortly before expiry or on a 401,
|
||||
/// with concurrent refreshes coalesced into one in-flight request.
|
||||
class MdblistClient implements DisposableTrackerClient {
|
||||
/// `/scrobble/*` answers 201 on success; the sync endpoints answer 200.
|
||||
static const Set<int> _defaultAllowedStatuses = {200, 201, 204};
|
||||
|
||||
TrackerSession _session;
|
||||
final TrackerHttpClient _http;
|
||||
final MdblistAuthService _auth;
|
||||
final void Function() onSessionInvalidated;
|
||||
final void Function(TrackerSession)? onSessionUpdated;
|
||||
|
||||
final _refreshCoalescer = FutureCoalescer<TrackerSession>();
|
||||
|
||||
MdblistClient(
|
||||
TrackerSession session, {
|
||||
required this.onSessionInvalidated,
|
||||
this.onSessionUpdated,
|
||||
http.Client? httpClient,
|
||||
MdblistAuthService? authService,
|
||||
}) : _session = session,
|
||||
_http = TrackerHttpClient(service: TrackerService.mdblist, logLabel: 'MDBList', httpClient: httpClient),
|
||||
_auth = authService ?? MdblistAuthService();
|
||||
|
||||
TrackerSession get session => _session;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_http.dispose();
|
||||
_auth.dispose();
|
||||
}
|
||||
|
||||
/// Current account. Used to populate the display name.
|
||||
Future<Map<String, dynamic>?> getUser() async {
|
||||
final res = await _request('GET', '/user');
|
||||
return res is Map ? res.cast<String, dynamic>() : null;
|
||||
}
|
||||
|
||||
/// Mark items watched. Body shape:
|
||||
/// ```
|
||||
/// {"movies": [{"ids": {"imdb": "tt0372784"}, "watched_at": "..."}]}
|
||||
/// ```
|
||||
Future<void> addToWatched(Map<String, dynamic> body) => _request('POST', '/sync/watched', body: body);
|
||||
|
||||
Future<void> removeFromWatched(Map<String, dynamic> body) => _request('POST', '/sync/watched/remove', body: body);
|
||||
|
||||
/// Report real-time playback. [action] is `start`, `pause` or `stop`.
|
||||
Future<void> scrobble(String action, Map<String, dynamic> body) => _request('POST', '/scrobble/$action', body: body);
|
||||
|
||||
Future<void> addRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings', body: body);
|
||||
|
||||
Future<void> removeRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings/remove', body: body);
|
||||
|
||||
/// All of the user's ratings, keyed by `movies` / `shows` / `seasons` /
|
||||
/// `episodes`. MDBList has no per-item rating lookup, so the caller filters.
|
||||
Future<List<dynamic>> getRatings(String type) async {
|
||||
final res = await _request('GET', '/sync/ratings');
|
||||
if (res is Map && res[type] is List) return res[type] as List<dynamic>;
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Best-effort server-side revoke, mirroring Trakt's disconnect. Failure is
|
||||
/// non-fatal: the local session is already gone by the time this runs.
|
||||
Future<void> revoke() async {
|
||||
try {
|
||||
await _http.sendForm(
|
||||
'POST',
|
||||
Uri.parse(MdblistConstants.revokeUrl),
|
||||
headers: MdblistConstants.headers(),
|
||||
body: {'token': _session.accessToken, 'client_id': MdblistConstants.clientId},
|
||||
timeout: TrackerConstants.revokeTimeout,
|
||||
operation: 'MDBList token revoke',
|
||||
allowedMethods: const {'POST'},
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.d('MDBList: revoke failed (non-fatal)', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<TrackerSession> _refresh() => _refreshCoalescer.run(_doRefresh);
|
||||
|
||||
Future<TrackerSession> _doRefresh() async {
|
||||
try {
|
||||
final fresh = await _auth.refresh(_session);
|
||||
_session = fresh;
|
||||
onSessionUpdated?.call(fresh);
|
||||
return fresh;
|
||||
} catch (e) {
|
||||
appLogger.w('MDBList: refresh failed', error: e);
|
||||
// Only a terminally-invalid grant clears the session; transient 5xx and
|
||||
// network failures fall through so a later 401 can retry.
|
||||
if (e is TrackerAuthException && e.isPermanent) onSessionInvalidated();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an authenticated request, refreshing on 401 and retrying once.
|
||||
Future<dynamic> _request(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Set<int> allowStatuses = _defaultAllowedStatuses,
|
||||
}) async {
|
||||
if (_session.needsRefresh) {
|
||||
try {
|
||||
await _refresh();
|
||||
} catch (_) {
|
||||
// Fall through; the request will hit 401 naturally and retry.
|
||||
}
|
||||
}
|
||||
|
||||
var res = await _send(method, path, body: body);
|
||||
|
||||
if (res.statusCode == 401) {
|
||||
// A failed refresh propagates its TrackerAuthException, matching Trakt.
|
||||
await _refresh();
|
||||
res = await _send(method, path, body: body);
|
||||
}
|
||||
|
||||
if (allowStatuses.contains(res.statusCode)) return TrackerHttpClient.decodeJson(res.body);
|
||||
|
||||
if (res.statusCode == 429) {
|
||||
throw TrackerRateLimitException(
|
||||
service: TrackerService.mdblist,
|
||||
retryAfterSeconds: int.tryParse(res.headers['retry-after'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
throw TrackerApiException(service: TrackerService.mdblist, statusCode: res.statusCode);
|
||||
}
|
||||
|
||||
Future<http.Response> _send(String method, String path, {Map<String, dynamic>? body}) {
|
||||
final uri = Uri.parse('${MdblistConstants.apiBase}$path');
|
||||
return _http.sendJson(
|
||||
method,
|
||||
uri,
|
||||
headers: MdblistConstants.headers(accessToken: _session.accessToken),
|
||||
body: body,
|
||||
allowedMethods: const {'GET', 'POST'},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// Bundled MDBList API credentials and endpoints.
|
||||
///
|
||||
/// Registered as a **Device Code** app at https://mdblist.com/developer. That
|
||||
/// app type takes no client secret and no redirect URI, so the public client
|
||||
/// ID below is the only credential in the binary — unlike Trakt, there is no
|
||||
/// extractable secret to worry about.
|
||||
class MdblistConstants {
|
||||
MdblistConstants._();
|
||||
|
||||
/// Registered MDBList Device Code app client ID. Public by design: the
|
||||
/// device-code grant authenticates the user, not the binary.
|
||||
static const String clientId = 'xOUwKUPdGEbHif6aKwW2gCxCAvFx7m0Q3jX0ZxXZ';
|
||||
|
||||
static const String apiBase = 'https://api.mdblist.com';
|
||||
static const String webBase = 'https://mdblist.com';
|
||||
|
||||
static const String appName = 'plezy';
|
||||
static const String appVersion = '2';
|
||||
|
||||
/// OAuth endpoints. MDBList runs django-oauth-toolkit, whose routes are
|
||||
/// registered with a trailing slash — dropping it 404s the request.
|
||||
static const String deviceAuthorizationUrl = '$apiBase/oauth/device-authorization/';
|
||||
static const String tokenUrl = '$apiBase/oauth/token/';
|
||||
static const String revokeUrl = '$apiBase/oauth/revoke_token/';
|
||||
|
||||
/// Page the user opens on a phone or laptop to approve the device.
|
||||
static const String verificationUrl = '$webBase/oauth/device/';
|
||||
|
||||
static const String deviceCodeGrantType = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
|
||||
/// The only scope MDBList offers. Grants the user's profile, lists,
|
||||
/// watchlist, ratings, collection and playback state — there is no
|
||||
/// read-only alternative to pick.
|
||||
static const String scope = 'write';
|
||||
|
||||
/// Prefilled activation URL for [userCode].
|
||||
///
|
||||
/// MDBList does not return `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. Building the URL here is what makes
|
||||
/// the dialog's "open to activate" button land on a filled-in form instead
|
||||
/// of an empty one. If the parameter is ever ignored the page still renders
|
||||
/// normally, so this degrades to manual entry rather than breaking.
|
||||
static String verificationUrlFor(String userCode, {String? verificationUrl}) => Uri.parse(
|
||||
verificationUrl ?? MdblistConstants.verificationUrl,
|
||||
).replace(queryParameters: {'user_code': userCode}).toString();
|
||||
|
||||
/// Headers for every MDBList API call. The token rides the `Authorization`
|
||||
/// header; the `?apikey=` query form is deliberately unused so credentials
|
||||
/// never enter a URL.
|
||||
static Map<String, String> headers({String? accessToken}) => {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': '$appName/$appVersion',
|
||||
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../media/media_kind.dart';
|
||||
import '../../../models/trackers/tracker_context.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/external_ids.dart';
|
||||
import '../../../utils/json_utils.dart';
|
||||
import '../tracker.dart';
|
||||
import '../tracker_constants.dart';
|
||||
import '../tracker_id_resolver.dart';
|
||||
import '../tracker_rating_match.dart';
|
||||
import '../tracker_session.dart';
|
||||
import '../tracker_write_queue.dart';
|
||||
import 'mdblist_client.dart';
|
||||
|
||||
/// MDBList tracker.
|
||||
///
|
||||
/// In-player playback is reported in real time through `POST /scrobble/start`,
|
||||
/// `/pause` and `/stop`; MDBList's own rule then decides watched state — a
|
||||
/// `stop` at or above 80% progress files the item under `/sync/watched` and
|
||||
/// deletes the session. `POST /sync/watched` covers the marks that never pass
|
||||
/// through the player: manual, container, offline replay and external players.
|
||||
///
|
||||
/// Matching is by IMDb and TMDb id only. MDBList's id block accepts
|
||||
/// `imdb`/`tmdb`/`trakt`/`kitsu`/`mdblist` but **not** `tvdb`, so an item that
|
||||
/// a media server only identifies by TVDB id cannot be written and is skipped
|
||||
/// rather than mismatched onto the wrong title.
|
||||
class MdblistTracker extends TrackerBase
|
||||
with ClientBackedTracker<MdblistClient>
|
||||
implements TrackerRatingSource, RealtimeScrobbleTracker, EpisodeHistoryTracker {
|
||||
static MdblistTracker? _instance;
|
||||
static MdblistTracker get instance => _instance ??= MdblistTracker._();
|
||||
MdblistTracker._();
|
||||
|
||||
@override
|
||||
String get name => 'mdblist';
|
||||
|
||||
@override
|
||||
TrackerService get service => TrackerService.mdblist;
|
||||
|
||||
/// MDBList carries no anime mapping of its own and takes plain external ids.
|
||||
@override
|
||||
bool get needsFribb => false;
|
||||
|
||||
/// MDBList counts a `/scrobble/stop` as a watch from this progress upwards.
|
||||
static const double _scrobbleWatchedPercent = 80.0;
|
||||
|
||||
/// The bound client is replaced on every session rebind, so its identity is
|
||||
/// the account identity.
|
||||
@override
|
||||
Object? get scrobbleBinding => client;
|
||||
|
||||
@override
|
||||
bool get canReportPlayback => isEnabledWithSession;
|
||||
|
||||
@override
|
||||
ScrobblePolicy get scrobblePolicy => const ScrobblePolicy(
|
||||
// MDBList documents no per-item scrobble cooldown, so this mirrors the
|
||||
// conservative Trakt window rather than re-sending `start` freely.
|
||||
resendThrottle: Duration(seconds: 30),
|
||||
// A slider drag emits many position updates; only one checkpoint per
|
||||
// window reaches MDBList.
|
||||
seekThrottle: Duration(seconds: 5),
|
||||
);
|
||||
|
||||
void rebindSession(
|
||||
TrackerSession? session, {
|
||||
required void Function() onSessionInvalidated,
|
||||
void Function(TrackerSession session)? onSessionUpdated,
|
||||
http.Client? httpClient,
|
||||
}) {
|
||||
rebindTrackerClient(
|
||||
session,
|
||||
createClient: (session) => MdblistClient(
|
||||
session,
|
||||
onSessionInvalidated: onSessionInvalidated,
|
||||
onSessionUpdated: onSessionUpdated,
|
||||
httpClient: httpClient,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// MDBList matches on the media server's own external ids and nothing else.
|
||||
@override
|
||||
String? historyRowIdentity(TrackerContext ctx) => trackerExternalRowIdentity(ctx.external);
|
||||
|
||||
@override
|
||||
Future<void> markWatched(TrackerContext ctx, {DateTime? watchedAt}) async {
|
||||
final client = this.client;
|
||||
if (client == null || !canWriteWatched) return;
|
||||
final body = _watchedBody(ctx, watchedAt: watchedAt);
|
||||
if (body == null) return;
|
||||
|
||||
await client.addToWatched(body);
|
||||
appLogger.d('MDBList: marked watched (${ctx.ratingKey}, isMovie=${ctx.isMovie})');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markUnwatched(TrackerContext ctx) async {
|
||||
final client = this.client;
|
||||
if (client == null || !canWriteWatched) return;
|
||||
final body = _watchedBody(ctx);
|
||||
if (body == null) return;
|
||||
|
||||
await client.removeFromWatched(body);
|
||||
appLogger.d('MDBList: marked unwatched (${ctx.ratingKey}, isMovie=${ctx.isMovie})');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> scrobble(TrackerContext ctx, TrackerScrobbleState state, double progressPercent) async {
|
||||
final client = this.client;
|
||||
if (client == null) return;
|
||||
final body = _scrobbleBody(ctx, progressPercent);
|
||||
if (body == null) return;
|
||||
|
||||
final action = switch (state) {
|
||||
TrackerScrobbleState.start => 'start',
|
||||
TrackerScrobbleState.pause => 'pause',
|
||||
// MDBList has no seek event, but `start` is documented as upserting the
|
||||
// session's progress, so one re-start checkpoints the new position
|
||||
// without the pause+start pair Trakt needs.
|
||||
TrackerScrobbleState.seek => 'start',
|
||||
TrackerScrobbleState.stop => 'stop',
|
||||
};
|
||||
await client.scrobble(action, body);
|
||||
appLogger.d('MDBList: scrobble ${state.name} @ ${progressPercent.toStringAsFixed(1)}%');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reconcileWatchedAfterStop(TrackerContext ctx, double progressPercent) async {
|
||||
// At or above MDBList's own rule the stop already recorded the watch; a
|
||||
// `/sync/watched` write would record a second one.
|
||||
if (progressPercent >= _scrobbleWatchedPercent) return;
|
||||
appLogger.d('MDBList: stop below ${_scrobbleWatchedPercent.toStringAsFixed(0)}% — recording watch explicitly');
|
||||
await markWatched(ctx);
|
||||
}
|
||||
|
||||
/// `/sync/watched` and its `/remove` sibling share one shape; the remove
|
||||
/// variant simply carries no timestamps.
|
||||
Map<String, dynamic>? _watchedBody(TrackerContext ctx, {DateTime? watchedAt}) {
|
||||
final ids = _ids(ctx.external);
|
||||
if (ids.isEmpty) return null;
|
||||
final stamp = watchedAt?.toUtc().toIso8601String();
|
||||
|
||||
if (ctx.isMovie) {
|
||||
return {
|
||||
'movies': [
|
||||
{'ids': ids, 'watched_at': ?stamp},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
final season = ctx.season;
|
||||
final number = ctx.episodeNumber;
|
||||
if (season == null || number == null) return null;
|
||||
return {
|
||||
'shows': [
|
||||
{
|
||||
'ids': ids,
|
||||
'seasons': [
|
||||
{
|
||||
'number': season,
|
||||
'episodes': [
|
||||
{'number': number, 'watched_at': ?stamp},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// Scrobble nests the episode inside the show as `show.season.episode`,
|
||||
/// unlike the sibling `episode` object Trakt and Simkl accept.
|
||||
Map<String, dynamic>? _scrobbleBody(TrackerContext ctx, double progressPercent) {
|
||||
final ids = _ids(ctx.external);
|
||||
if (ids.isEmpty) return null;
|
||||
// MDBList rejects a progress outside 0-100; clamp rather than let a
|
||||
// rounding overshoot fail the whole report.
|
||||
final progress = double.parse(progressPercent.clamp(0, 100).toStringAsFixed(2));
|
||||
|
||||
if (ctx.isMovie) {
|
||||
return {
|
||||
'movie': {'ids': ids},
|
||||
'progress': progress,
|
||||
};
|
||||
}
|
||||
|
||||
final season = ctx.season;
|
||||
final number = ctx.episodeNumber;
|
||||
if (season == null || number == null) return null;
|
||||
return {
|
||||
'show': {
|
||||
'ids': ids,
|
||||
'season': {
|
||||
'number': season,
|
||||
'episode': {'number': number},
|
||||
},
|
||||
},
|
||||
'progress': progress,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolve the active client plus a non-empty id block, or refuse. Without
|
||||
/// the id check a TVDB-only item would post `"ids": {}`, which MDBList would
|
||||
/// accept as a write against nothing.
|
||||
(MdblistClient, Map<String, Object>) _ratingTarget(TrackerRatingContext ctx) {
|
||||
final activeClient = client;
|
||||
if (activeClient == null) throw const TrackerRatingUnavailableException('MDBList');
|
||||
final ids = _ids(ctx.ids.external);
|
||||
if (ids.isEmpty) throw const TrackerRatingUnavailableException('MDBList');
|
||||
return (activeClient, ids);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int?> getRating(TrackerRatingContext ctx) async {
|
||||
final (client, localIds) = _ratingTarget(ctx);
|
||||
|
||||
final entries = await client.getRatings(_ratingType(ctx));
|
||||
for (final entry in entries) {
|
||||
if (entry is! Map) continue;
|
||||
final map = entry.cast<String, dynamic>();
|
||||
if (!_ratingEntryMatches(ctx, map, localIds)) continue;
|
||||
final rating = flexibleInt(map['rating']);
|
||||
return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rate(TrackerRatingContext ctx, int score) async {
|
||||
final (client, ids) = _ratingTarget(ctx);
|
||||
await client.addRatings(_ratingBody(ctx, ids, rating: score.clamp(1, 10).toInt()));
|
||||
appLogger.d('MDBList: updated score (${ctx.kind.name}, score=$score)');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearRating(TrackerRatingContext ctx) async {
|
||||
final (client, ids) = _ratingTarget(ctx);
|
||||
await client.removeRatings(_ratingBody(ctx, ids));
|
||||
appLogger.d('MDBList: cleared score (${ctx.kind.name})');
|
||||
}
|
||||
|
||||
String _ratingType(TrackerRatingContext ctx) => switch (ctx.kind) {
|
||||
MediaKind.movie => 'movies',
|
||||
MediaKind.show => 'shows',
|
||||
MediaKind.season => 'seasons',
|
||||
MediaKind.episode => 'episodes',
|
||||
_ => throw const TrackerRatingUnavailableException('MDBList'),
|
||||
};
|
||||
|
||||
bool _ratingEntryMatches(TrackerRatingContext ctx, Map<String, dynamic> entry, Map<String, Object> localIds) {
|
||||
final show = entry['show'];
|
||||
final movie = entry['movie'];
|
||||
return switch (ctx.kind) {
|
||||
MediaKind.movie => trackerIdsMatch(trackerNestedIds(movie), localIds),
|
||||
MediaKind.show => trackerIdsMatch(trackerNestedIds(show), localIds),
|
||||
MediaKind.season =>
|
||||
trackerIdsMatch(trackerNestedIds(_nestedShow(entry['season']) ?? show), localIds) &&
|
||||
_numberMatches(entry['season'], ctx.season),
|
||||
MediaKind.episode =>
|
||||
trackerIdsMatch(trackerNestedIds(_nestedShow(entry['episode']) ?? show), localIds) &&
|
||||
_numberMatches(entry['episode'], ctx.episodeNumber) &&
|
||||
_seasonMatches(entry['episode'], ctx.season),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Season and episode rating rows carry their parent show inline rather than
|
||||
/// as a sibling key, so prefer that when present.
|
||||
Object? _nestedShow(Object? value) => value is Map ? value['show'] : null;
|
||||
|
||||
bool _numberMatches(Object? value, int? expected) {
|
||||
if (expected == null || value is! Map) return false;
|
||||
return flexibleInt(value['number']) == expected;
|
||||
}
|
||||
|
||||
bool _seasonMatches(Object? value, int? expected) {
|
||||
if (expected == null || value is! Map) return false;
|
||||
return flexibleInt(value['season']) == expected;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _ratingBody(TrackerRatingContext ctx, Map<String, Object> ids, {int? rating}) {
|
||||
final item = {'ids': ids, 'rating': ?rating};
|
||||
|
||||
return switch (ctx.kind) {
|
||||
MediaKind.movie => {
|
||||
'movies': [item],
|
||||
},
|
||||
MediaKind.show => {
|
||||
'shows': [item],
|
||||
},
|
||||
MediaKind.season => {
|
||||
'shows': [
|
||||
{
|
||||
'ids': ids,
|
||||
'seasons': [
|
||||
{'number': ctx.season, 'rating': ?rating},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
MediaKind.episode => {
|
||||
'shows': [
|
||||
{
|
||||
'ids': ids,
|
||||
'seasons': [
|
||||
{
|
||||
'number': ctx.season,
|
||||
'episodes': [
|
||||
{'number': ctx.episodeNumber, 'rating': ?rating},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
_ => throw const TrackerRatingUnavailableException('MDBList'),
|
||||
};
|
||||
}
|
||||
|
||||
/// MDBList's id block. TVDB is deliberately absent — the API does not accept
|
||||
/// it, so a TVDB-only item yields an empty map and every write no-ops.
|
||||
Map<String, Object> _ids(ExternalIds external) => {'imdb': ?external.imdb, 'tmdb': ?external.tmdb};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class TrackerAccountStore {
|
||||
TrackerService.anilist: TrackerAccountStore._(TrackerService.anilist, 'anilist_session'),
|
||||
TrackerService.simkl: TrackerAccountStore._(TrackerService.simkl, 'simkl_session'),
|
||||
TrackerService.trakt: TrackerAccountStore._(TrackerService.trakt, 'trakt_session'),
|
||||
TrackerService.mdblist: TrackerAccountStore._(TrackerService.mdblist, 'mdblist_session'),
|
||||
};
|
||||
|
||||
static TrackerAccountStore forService(TrackerService service) => _stores[service]!;
|
||||
|
||||
@@ -19,7 +19,7 @@ class TrackerConstants {
|
||||
/// Identifier used across the app to disambiguate per-service operations.
|
||||
/// The enum's `.name` forms part of the persistence key — do not rename
|
||||
/// without a migration.
|
||||
enum TrackerService { mal, anilist, simkl, trakt }
|
||||
enum TrackerService { mal, anilist, simkl, trakt, mdblist }
|
||||
|
||||
/// Blacklist+[] syncs every library (the default); whitelist+[] syncs nothing.
|
||||
enum TrackerLibraryFilterMode { blacklist, whitelist }
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'anime_lists_mapping_store.dart';
|
||||
import 'anilist/anilist_tracker.dart';
|
||||
import 'fribb_mapping_store.dart';
|
||||
import 'mal/mal_tracker.dart';
|
||||
import 'mdblist/mdblist_tracker.dart';
|
||||
import 'simkl/simkl_tracker.dart';
|
||||
import 'tracker.dart';
|
||||
import 'tracker_constants.dart';
|
||||
@@ -24,10 +25,10 @@ import 'trakt/trakt_tracker.dart';
|
||||
///
|
||||
/// Three mechanisms, chosen per tracker kind:
|
||||
///
|
||||
/// * [RealtimeScrobbleTracker]s (Simkl, Trakt) receive the playback lifecycle —
|
||||
/// start/resume, pause, seek, stop — with the current progress, and decide
|
||||
/// watched state themselves. They are excluded from the threshold fan-out so
|
||||
/// a single watch never produces two writes.
|
||||
/// * [RealtimeScrobbleTracker]s (Simkl, Trakt, MDBList) receive the playback
|
||||
/// lifecycle — start/resume, pause, seek, stop — with the current progress,
|
||||
/// and decide watched state themselves. They are excluded from the threshold
|
||||
/// fan-out so a single watch never produces two writes.
|
||||
/// * Threshold trackers (MAL, AniList) are notified exactly once when progress
|
||||
/// crosses the watched threshold, with a safety-net fire on stop if the
|
||||
/// crossing was missed (e.g. the user stopped between ticks).
|
||||
@@ -51,6 +52,7 @@ class TrackerCoordinator {
|
||||
AnilistTracker.instance,
|
||||
SimklTracker.instance,
|
||||
TraktTracker.instance,
|
||||
MdblistTracker.instance,
|
||||
];
|
||||
|
||||
/// One transport per real-time tracker, created once and outliving individual
|
||||
|
||||
@@ -82,6 +82,7 @@ class TrackerSession {
|
||||
switch (service) {
|
||||
case TrackerService.mal:
|
||||
case TrackerService.trakt:
|
||||
case TrackerService.mdblist:
|
||||
_validateRefreshToken(service, refreshToken);
|
||||
requireExpiry();
|
||||
case TrackerService.anilist:
|
||||
@@ -119,6 +120,15 @@ class TrackerSession {
|
||||
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?),
|
||||
|
||||
Reference in New Issue
Block a user