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:
@@ -63,9 +63,9 @@ void main() {
|
||||
final companionProviders = <CompanionRemoteProvider>[];
|
||||
final disposedActiveIds = <String>[];
|
||||
final trackerHttpClients = <FakeHttpClient>[];
|
||||
// TrackersProvider owns five eager auth HTTP clients across the four
|
||||
// TrackersProvider owns six eager auth HTTP clients across the five
|
||||
// services (MAL's proxy and token exchange use separate clients).
|
||||
const trackerAuthClientsPerProfile = 5;
|
||||
const trackerAuthClientsPerProfile = 6;
|
||||
FakeHttpClient trackerHttpClientFactory() {
|
||||
final client = FakeHttpClient(200, const <int>[]);
|
||||
trackerHttpClients.add(client);
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
||||
import 'package:plezy/services/trackers/mdblist/mdblist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_tracker.dart';
|
||||
|
||||
@@ -19,6 +20,7 @@ final _malStore = trackerAccountStore(TrackerService.mal);
|
||||
final _anilistStore = trackerAccountStore(TrackerService.anilist);
|
||||
final _simklStore = trackerAccountStore(TrackerService.simkl);
|
||||
final _traktStore = trackerAccountStore(TrackerService.trakt);
|
||||
final _mdblistStore = trackerAccountStore(TrackerService.mdblist);
|
||||
|
||||
TrackerSession _mal({String? username}) => TrackerSession(
|
||||
accessToken: 'mal-at',
|
||||
@@ -68,18 +70,22 @@ void main() {
|
||||
expect(p.anilist, isNull);
|
||||
expect(p.simkl, isNull);
|
||||
expect(p.trakt, isNull);
|
||||
expect(p.mdblist, isNull);
|
||||
expect(p.isMalConnected, isFalse);
|
||||
expect(p.isAnilistConnected, isFalse);
|
||||
expect(p.isSimklConnected, isFalse);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(p.isMdblistConnected, isFalse);
|
||||
expect(p.malUsername, isNull);
|
||||
expect(p.anilistUsername, isNull);
|
||||
expect(p.simklUsername, isNull);
|
||||
expect(p.traktUsername, isNull);
|
||||
expect(p.mdblistUsername, isNull);
|
||||
expect(p.isConnecting(TrackerService.mal), isFalse);
|
||||
expect(p.isConnecting(TrackerService.anilist), isFalse);
|
||||
expect(p.isConnecting(TrackerService.simkl), isFalse);
|
||||
expect(p.isConnecting(TrackerService.trakt), isFalse);
|
||||
expect(p.isConnecting(TrackerService.mdblist), isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
@@ -94,8 +100,8 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
expect(clients, hasLength(5));
|
||||
expect(clients.toSet(), hasLength(5));
|
||||
expect(clients, hasLength(6));
|
||||
expect(clients.toSet(), hasLength(6));
|
||||
for (final client in clients) {
|
||||
expect(client.closeCount, 0);
|
||||
}
|
||||
@@ -264,7 +270,13 @@ void main() {
|
||||
// Post-dispose rebind should not throw.
|
||||
await _bindProfile(p, 'any-uuid');
|
||||
});
|
||||
for (final service in [TrackerService.mal, TrackerService.anilist, TrackerService.simkl, TrackerService.trakt]) {
|
||||
for (final service in [
|
||||
TrackerService.mal,
|
||||
TrackerService.anilist,
|
||||
TrackerService.simkl,
|
||||
TrackerService.trakt,
|
||||
TrackerService.mdblist,
|
||||
]) {
|
||||
test('$service stale connect cannot save or replace a newer binding after dispose', () async {
|
||||
const oldUuid = 'profile-old';
|
||||
const newUuid = 'profile-new';
|
||||
@@ -422,6 +434,7 @@ void _resetTrackerBindings() {
|
||||
AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
MdblistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
}
|
||||
|
||||
TrackerAccountStore _store(TrackerService service) => switch (service) {
|
||||
@@ -429,6 +442,7 @@ TrackerAccountStore _store(TrackerService service) => switch (service) {
|
||||
TrackerService.anilist => _anilistStore,
|
||||
TrackerService.simkl => _simklStore,
|
||||
TrackerService.trakt => _traktStore,
|
||||
TrackerService.mdblist => _mdblistStore,
|
||||
};
|
||||
|
||||
TrackerSession _session(TrackerService service, String owner) => switch (service) {
|
||||
@@ -453,6 +467,13 @@ TrackerSession _session(TrackerService service, String owner) => switch (service
|
||||
createdAt: 1900000000,
|
||||
username: owner,
|
||||
),
|
||||
TrackerService.mdblist => TrackerSession(
|
||||
accessToken: '$owner-mdblist-at',
|
||||
refreshToken: '$owner-mdblist-rt',
|
||||
expiresAt: 2000000000,
|
||||
createdAt: 1900000000,
|
||||
username: owner,
|
||||
),
|
||||
};
|
||||
|
||||
Future<bool> _connect(TrackersProvider provider, TrackerService service) => switch (service) {
|
||||
@@ -460,6 +481,7 @@ Future<bool> _connect(TrackersProvider provider, TrackerService service) => swit
|
||||
TrackerService.anilist => provider.connectAnilist(onCodeReady: (_) {}),
|
||||
TrackerService.simkl => provider.connectSimkl(onCodeReady: (_) {}),
|
||||
TrackerService.trakt => provider.connectTrakt(onCodeReady: (_) {}),
|
||||
TrackerService.mdblist => provider.connectMdblist(onCodeReady: (_) {}),
|
||||
};
|
||||
|
||||
TrackerSession? _providerSession(TrackersProvider provider, TrackerService service) => switch (service) {
|
||||
@@ -467,6 +489,7 @@ TrackerSession? _providerSession(TrackersProvider provider, TrackerService servi
|
||||
TrackerService.anilist => provider.anilist,
|
||||
TrackerService.simkl => provider.simkl,
|
||||
TrackerService.trakt => provider.trakt,
|
||||
TrackerService.mdblist => provider.mdblist,
|
||||
};
|
||||
|
||||
Object? _boundClient(TrackerService service) => switch (service) {
|
||||
@@ -474,6 +497,7 @@ Object? _boundClient(TrackerService service) => switch (service) {
|
||||
TrackerService.anilist => AnilistTracker.instance.client,
|
||||
TrackerService.simkl => SimklTracker.instance.client,
|
||||
TrackerService.trakt => TraktTracker.instance.client,
|
||||
TrackerService.mdblist => MdblistTracker.instance.client,
|
||||
};
|
||||
|
||||
TrackerSession? _boundSession(TrackerService service) => switch (service) {
|
||||
@@ -481,6 +505,7 @@ TrackerSession? _boundSession(TrackerService service) => switch (service) {
|
||||
TrackerService.anilist => AnilistTracker.instance.client?.session,
|
||||
TrackerService.simkl => SimklTracker.instance.client?.session,
|
||||
TrackerService.trakt => TraktTracker.instance.client?.session,
|
||||
TrackerService.mdblist => MdblistTracker.instance.client?.session,
|
||||
};
|
||||
|
||||
class _ControlledConnectPipeline {
|
||||
|
||||
@@ -56,7 +56,7 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
expect(clients, hasLength(5));
|
||||
expect(clients, hasLength(6));
|
||||
for (final client in clients) {
|
||||
expect(client.closeCount, 0);
|
||||
}
|
||||
|
||||
@@ -612,8 +612,8 @@ class _SettingsHarness {
|
||||
activeProfile.dispose();
|
||||
await plexHome.dispose();
|
||||
await database.close();
|
||||
expect(trackerHttpClients, hasLength(5));
|
||||
expect(trackerHttpClients.toSet(), hasLength(5));
|
||||
expect(trackerHttpClients, hasLength(6));
|
||||
expect(trackerHttpClients.toSet(), hasLength(6));
|
||||
for (final client in trackerHttpClients) {
|
||||
expect(client.closeCount, 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/models/trackers/device_code.dart';
|
||||
import 'package:plezy/services/trackers/device_code_auth_service.dart';
|
||||
import 'package:plezy/services/trackers/mdblist/mdblist_auth_service.dart';
|
||||
import 'package:plezy/services/trackers/mdblist/mdblist_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
|
||||
const _code = DeviceCode(
|
||||
deviceCode: 'dev-code',
|
||||
userCode: 'LPF9MQ3Q',
|
||||
verificationUrl: MdblistConstants.verificationUrl,
|
||||
expiresIn: 1800,
|
||||
interval: 5,
|
||||
);
|
||||
|
||||
TrackerSession _current() => TrackerSession(
|
||||
accessToken: 'old-at',
|
||||
refreshToken: 'old-rt',
|
||||
expiresAt: 2000000000,
|
||||
createdAt: 1900000000,
|
||||
username: 'edde',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('createDeviceCode', () {
|
||||
test('posts the public client id and builds a prefilled activation URL', () async {
|
||||
late http.Request captured;
|
||||
final auth = MdblistAuthService(
|
||||
httpClient: MockClient((req) async {
|
||||
captured = req;
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'verification_uri': 'https://mdblist.com/oauth/device/',
|
||||
'expires_in': 1800,
|
||||
'user_code': 'LPF9MQ3Q',
|
||||
'device_code': 'dev-code',
|
||||
'interval': 5,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(auth.dispose);
|
||||
|
||||
final code = await auth.createDeviceCode();
|
||||
|
||||
expect(captured.url.toString(), MdblistConstants.deviceAuthorizationUrl);
|
||||
// A device-code app carries no secret: client id and scope are the whole
|
||||
// request.
|
||||
expect(captured.bodyFields, {'client_id': MdblistConstants.clientId, 'scope': 'write'});
|
||||
expect(code.deviceCode, 'dev-code');
|
||||
expect(code.userCode, 'LPF9MQ3Q');
|
||||
expect(code.verificationUrl, 'https://mdblist.com/oauth/device/');
|
||||
// MDBList sends no `verification_uri_complete`; the prefilled link is
|
||||
// what makes the dialog's open button land on a filled-in form.
|
||||
expect(code.verificationUrlComplete, 'https://mdblist.com/oauth/device/?user_code=LPF9MQ3Q');
|
||||
expect(code.expiresIn, 1800);
|
||||
expect(code.interval, 5);
|
||||
});
|
||||
|
||||
test('prefers a server-supplied complete URL when one appears', () async {
|
||||
final auth = MdblistAuthService(
|
||||
httpClient: MockClient(
|
||||
(_) async => http.Response(
|
||||
json.encode({
|
||||
'verification_uri': 'https://mdblist.com/oauth/device/',
|
||||
'verification_uri_complete': 'https://mdblist.com/short/ABCD',
|
||||
'expires_in': 1800,
|
||||
'user_code': 'ABCD',
|
||||
'device_code': 'dev-code',
|
||||
'interval': 5,
|
||||
}),
|
||||
200,
|
||||
),
|
||||
),
|
||||
);
|
||||
addTearDown(auth.dispose);
|
||||
|
||||
expect((await auth.createDeviceCode()).verificationUrlComplete, 'https://mdblist.com/short/ABCD');
|
||||
});
|
||||
|
||||
test('throws when the device-code request is rejected', () async {
|
||||
final auth = MdblistAuthService(
|
||||
httpClient: MockClient((_) async => http.Response('{"error": "invalid_request"}', 400)),
|
||||
);
|
||||
addTearDown(auth.dispose);
|
||||
|
||||
await expectLater(auth.createDeviceCode(), throwsA(isA<DeviceCodeAuthFlowException>()));
|
||||
});
|
||||
});
|
||||
|
||||
group('probe', () {
|
||||
Future<DevicePollEvent> probeWith(int status, String body) async {
|
||||
final auth = MdblistAuthService(httpClient: MockClient((_) async => http.Response(body, status)));
|
||||
addTearDown(auth.dispose);
|
||||
return auth.probe(_code);
|
||||
}
|
||||
|
||||
// MDBList reports every non-terminal state as HTTP 400; only the body's
|
||||
// `error` distinguishes them, so the status code must not drive this.
|
||||
test('maps authorization_pending to pending', () async {
|
||||
expect(await probeWith(400, '{"error": "authorization_pending"}'), isA<DevicePollPending>());
|
||||
});
|
||||
|
||||
test('maps slow_down to a backoff', () async {
|
||||
expect(await probeWith(400, '{"error": "slow_down"}'), isA<DevicePollSlowDown>());
|
||||
});
|
||||
|
||||
test('maps access_denied to denied', () async {
|
||||
expect(await probeWith(400, '{"error": "access_denied"}'), isA<DevicePollDenied>());
|
||||
});
|
||||
|
||||
test('maps expired_token to expired', () async {
|
||||
expect(await probeWith(400, '{"error": "expired_token"}'), isA<DevicePollExpired>());
|
||||
});
|
||||
|
||||
test('treats device_not_found as expired', () async {
|
||||
expect(await probeWith(404, '{"error": "device_not_found"}'), isA<DevicePollExpired>());
|
||||
});
|
||||
|
||||
test('returns the token response on success', () async {
|
||||
final event = await probeWith(200, '{"access_token": "at", "refresh_token": "rt", "expires_in": 2592000}');
|
||||
expect(event, isA<DevicePollSuccess>());
|
||||
expect((event as DevicePollSuccess).tokenResponse['access_token'], 'at');
|
||||
});
|
||||
|
||||
test('keeps polling through an unparseable error page', () async {
|
||||
expect(await probeWith(502, '<html>bad gateway</html>'), isA<DevicePollPending>());
|
||||
});
|
||||
|
||||
test('keeps polling when the request itself fails', () async {
|
||||
final auth = MdblistAuthService(httpClient: MockClient((_) async => throw const SocketExceptionStub()));
|
||||
addTearDown(auth.dispose);
|
||||
expect(await auth.probe(_code), isA<DevicePollPending>());
|
||||
});
|
||||
});
|
||||
|
||||
group('refresh', () {
|
||||
test('sends the refresh grant without a secret and keeps the username', () async {
|
||||
late http.Request captured;
|
||||
final auth = MdblistAuthService(
|
||||
httpClient: MockClient((req) async {
|
||||
captured = req;
|
||||
return http.Response(
|
||||
json.encode({'access_token': 'new-at', 'refresh_token': 'new-rt', 'expires_in': 2592000, 'scope': 'write'}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(auth.dispose);
|
||||
|
||||
final fresh = await auth.refresh(_current());
|
||||
|
||||
expect(captured.url.toString(), MdblistConstants.tokenUrl);
|
||||
expect(captured.bodyFields, {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': 'old-rt',
|
||||
'client_id': MdblistConstants.clientId,
|
||||
});
|
||||
expect(fresh.accessToken, 'new-at');
|
||||
expect(fresh.refreshToken, 'new-rt');
|
||||
expect(fresh.scope, 'write');
|
||||
// The token endpoint never echoes the account name, so it must survive
|
||||
// the rotation rather than blanking the settings row.
|
||||
expect(fresh.username, 'edde');
|
||||
});
|
||||
|
||||
test('treats an invalid grant as permanent', () async {
|
||||
final auth = MdblistAuthService(
|
||||
httpClient: MockClient((_) async => http.Response('{"error": "invalid_grant"}', 400)),
|
||||
);
|
||||
addTearDown(auth.dispose);
|
||||
|
||||
await expectLater(
|
||||
auth.refresh(_current()),
|
||||
throwsA(isA<TrackerAuthException>().having((e) => e.isPermanent, 'isPermanent', isTrue)),
|
||||
);
|
||||
});
|
||||
|
||||
test('treats a server error as transient so the session survives', () async {
|
||||
final auth = MdblistAuthService(httpClient: MockClient((_) async => http.Response('nope', 503)));
|
||||
addTearDown(auth.dispose);
|
||||
|
||||
await expectLater(
|
||||
auth.refresh(_current()),
|
||||
throwsA(isA<TrackerAuthException>().having((e) => e.isPermanent, 'isPermanent', isFalse)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Stand-in for a transport failure; the poll loop must swallow it rather than
|
||||
/// abandoning an authorization the user may still be completing.
|
||||
class SocketExceptionStub implements Exception {
|
||||
const SocketExceptionStub();
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/trackers/tracker_context.dart';
|
||||
import 'package:plezy/services/trackers/mdblist/mdblist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_id_resolver.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
typedef _Call = ({String path, Map<String, dynamic> body});
|
||||
|
||||
class _Recorder {
|
||||
final List<_Call> calls = [];
|
||||
|
||||
http.Client client() => MockClient((req) async {
|
||||
calls.add((path: req.url.path, body: json.decode(req.body) as Map<String, dynamic>));
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
}
|
||||
|
||||
/// Far-future expiry: a session that looks stale would send the tracker down
|
||||
/// the refresh path and out to the real network.
|
||||
TrackerSession _session() =>
|
||||
TrackerSession(accessToken: 'at', refreshToken: 'rt', expiresAt: 4000000000, createdAt: 1900000000);
|
||||
|
||||
TrackerContext _episode({ExternalIds external = const ExternalIds(imdb: 'tt0903747', tmdb: 1396)}) =>
|
||||
TrackerContext.episode(
|
||||
external: external,
|
||||
anime: null,
|
||||
ratingKey: 'episode-1',
|
||||
libraryGlobalKey: null,
|
||||
season: 2,
|
||||
episodeNumber: 5,
|
||||
);
|
||||
|
||||
TrackerContext _movie({ExternalIds external = const ExternalIds(imdb: 'tt0372784', tmdb: 272)}) =>
|
||||
TrackerContext.movie(external: external, anime: null, ratingKey: 'movie-1', libraryGlobalKey: null);
|
||||
|
||||
void main() {
|
||||
late _Recorder recorder;
|
||||
|
||||
setUp(() async {
|
||||
recorder = _Recorder();
|
||||
MdblistTracker.instance.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client());
|
||||
await MdblistTracker.instance.setEnabled(true);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
MdblistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
await MdblistTracker.instance.setEnabled(false);
|
||||
});
|
||||
|
||||
group('scrobble', () {
|
||||
test('nests the episode inside the show as season.episode', () async {
|
||||
await MdblistTracker.instance.scrobble(_episode(), TrackerScrobbleState.start, 42.755);
|
||||
|
||||
expect(recorder.calls.single.path, '/scrobble/start');
|
||||
expect(recorder.calls.single.body, {
|
||||
'show': {
|
||||
'ids': {'imdb': 'tt0903747', 'tmdb': 1396},
|
||||
'season': {
|
||||
'number': 2,
|
||||
'episode': {'number': 5},
|
||||
},
|
||||
},
|
||||
'progress': 42.76,
|
||||
});
|
||||
});
|
||||
|
||||
test('sends a movie as a flat ids block', () async {
|
||||
await MdblistTracker.instance.scrobble(_movie(), TrackerScrobbleState.stop, 91.0);
|
||||
|
||||
expect(recorder.calls.single.path, '/scrobble/stop');
|
||||
expect(recorder.calls.single.body, {
|
||||
'movie': {
|
||||
'ids': {'imdb': 'tt0372784', 'tmdb': 272},
|
||||
},
|
||||
'progress': 91.0,
|
||||
});
|
||||
});
|
||||
|
||||
test('checkpoints a seek through start, the endpoint that upserts progress', () async {
|
||||
await MdblistTracker.instance.scrobble(_movie(), TrackerScrobbleState.seek, 30.0);
|
||||
|
||||
expect(recorder.calls.single.path, '/scrobble/start');
|
||||
});
|
||||
|
||||
test('clamps an overshooting progress into the accepted range', () async {
|
||||
await MdblistTracker.instance.scrobble(_movie(), TrackerScrobbleState.stop, 100.4);
|
||||
|
||||
expect(recorder.calls.single.body['progress'], 100.0);
|
||||
});
|
||||
});
|
||||
|
||||
group('watched history', () {
|
||||
test('records an episode with the replayed timestamp', () async {
|
||||
await MdblistTracker.instance.markWatched(_episode(), watchedAt: DateTime.utc(2026, 3, 1, 12, 30));
|
||||
|
||||
expect(recorder.calls.single.path, '/sync/watched');
|
||||
expect(recorder.calls.single.body, {
|
||||
'shows': [
|
||||
{
|
||||
'ids': {'imdb': 'tt0903747', 'tmdb': 1396},
|
||||
'seasons': [
|
||||
{
|
||||
'number': 2,
|
||||
'episodes': [
|
||||
{'number': 5, 'watched_at': '2026-03-01T12:30:00.000Z'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('omits the timestamp on a live mark', () async {
|
||||
await MdblistTracker.instance.markWatched(_movie());
|
||||
|
||||
expect(recorder.calls.single.body, {
|
||||
'movies': [
|
||||
{
|
||||
'ids': {'imdb': 'tt0372784', 'tmdb': 272},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('removes through the dedicated endpoint without a timestamp', () async {
|
||||
await MdblistTracker.instance.markUnwatched(_episode());
|
||||
|
||||
expect(recorder.calls.single.path, '/sync/watched/remove');
|
||||
final season = (recorder.calls.single.body['shows'] as List).single as Map<String, dynamic>;
|
||||
final episode = ((season['seasons'] as List).single as Map<String, dynamic>)['episodes'] as List;
|
||||
expect((episode.single as Map<String, dynamic>).containsKey('watched_at'), isFalse);
|
||||
});
|
||||
|
||||
test('stays silent when the tracker is disabled', () async {
|
||||
await MdblistTracker.instance.setEnabled(false);
|
||||
|
||||
await MdblistTracker.instance.markWatched(_movie());
|
||||
|
||||
expect(recorder.calls, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// MDBList's id block has no `tvdb` field, so an item the media server only
|
||||
// identifies by TVDB must be skipped rather than written under a partial or
|
||||
// wrong identity.
|
||||
group('unusable ids', () {
|
||||
test('writes nothing for a TVDB-only episode', () async {
|
||||
await MdblistTracker.instance.markWatched(_episode(external: const ExternalIds(tvdb: 81189)));
|
||||
await MdblistTracker.instance.scrobble(
|
||||
_episode(external: const ExternalIds(tvdb: 81189)),
|
||||
TrackerScrobbleState.start,
|
||||
10,
|
||||
);
|
||||
|
||||
expect(recorder.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('still writes when only one supported id is present', () async {
|
||||
await MdblistTracker.instance.markWatched(_movie(external: const ExternalIds(tmdb: 272)));
|
||||
|
||||
expect((recorder.calls.single.body['movies'] as List).single, {
|
||||
'ids': {'tmdb': 272},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('reconcileWatchedAfterStop', () {
|
||||
test('leaves the watch to MDBList at or above its own 80% rule', () async {
|
||||
await MdblistTracker.instance.reconcileWatchedAfterStop(_movie(), 80.0);
|
||||
|
||||
expect(recorder.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('records the watch explicitly below the rule', () async {
|
||||
await MdblistTracker.instance.reconcileWatchedAfterStop(_movie(), 79.9);
|
||||
|
||||
expect(recorder.calls.single.path, '/sync/watched');
|
||||
});
|
||||
});
|
||||
|
||||
group('ratings', () {
|
||||
test('rates a season through the nested show shape', () async {
|
||||
await MdblistTracker.instance.rate(
|
||||
TrackerRatingContext(
|
||||
ids: const TrackerIds(external: ExternalIds(imdb: 'tt0903747'), anime: null),
|
||||
kind: MediaKind.season,
|
||||
season: 2,
|
||||
),
|
||||
9,
|
||||
);
|
||||
|
||||
expect(recorder.calls.single.path, '/sync/ratings');
|
||||
expect(recorder.calls.single.body, {
|
||||
'shows': [
|
||||
{
|
||||
'ids': {'imdb': 'tt0903747'},
|
||||
'seasons': [
|
||||
{'number': 2, 'rating': 9},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('clears a movie rating without sending a score', () async {
|
||||
await MdblistTracker.instance.clearRating(
|
||||
TrackerRatingContext(
|
||||
ids: const TrackerIds(external: ExternalIds(imdb: 'tt0372784'), anime: null),
|
||||
kind: MediaKind.movie,
|
||||
),
|
||||
);
|
||||
|
||||
expect(recorder.calls.single.path, '/sync/ratings/remove');
|
||||
expect((recorder.calls.single.body['movies'] as List).single, {
|
||||
'ids': {'imdb': 'tt0372784'},
|
||||
});
|
||||
});
|
||||
|
||||
test('reports unavailable when no supported id is present', () async {
|
||||
await expectLater(
|
||||
MdblistTracker.instance.rate(
|
||||
TrackerRatingContext(
|
||||
ids: const TrackerIds(external: ExternalIds(tvdb: 81189), anime: null),
|
||||
kind: MediaKind.movie,
|
||||
),
|
||||
7,
|
||||
),
|
||||
throwsA(isA<TrackerRatingUnavailableException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user