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.
326 lines
11 KiB
Dart
326 lines
11 KiB
Dart
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};
|
|
}
|