Files
plezy/test/services/trackers/mdblist_tracker_test.dart
T
edde746 eaa1736c4e 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.
2026-08-05 12:03:05 +02:00

241 lines
7.8 KiB
Dart

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>()),
);
});
});
}