From eaa1736c4e46622d36b43f22db01a71447a8fdf2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:39:58 +0200 Subject: [PATCH] feat(mdblist): sync watched history, scrobbles and ratings with MDBList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/mdblist_mark.svg | 1 + lib/i18n/az.i18n.json | 3 +- lib/i18n/bg.i18n.json | 3 +- lib/i18n/da.i18n.json | 3 +- lib/i18n/de.i18n.json | 3 +- lib/i18n/en.i18n.json | 3 +- lib/i18n/es.i18n.json | 3 +- lib/i18n/fr.i18n.json | 3 +- lib/i18n/hu.i18n.json | 3 +- lib/i18n/it.i18n.json | 3 +- lib/i18n/ja.i18n.json | 3 +- lib/i18n/kk.i18n.json | 3 +- lib/i18n/ko.i18n.json | 3 +- lib/i18n/nb.i18n.json | 3 +- lib/i18n/nl.i18n.json | 3 +- lib/i18n/pl.i18n.json | 3 +- lib/i18n/pt.i18n.json | 3 +- lib/i18n/ru.i18n.json | 3 +- lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 4 + lib/i18n/sv.i18n.json | 3 +- lib/i18n/tr.i18n.json | 3 +- lib/i18n/uz.i18n.json | 3 +- lib/i18n/zh-Hant.i18n.json | 3 +- lib/i18n/zh.i18n.json | 3 +- lib/models/catalog/catalog_item.dart | 2 +- lib/providers/trackers_provider.dart | 64 +++- .../settings/tracker_service_info.dart | 7 + .../settings/tracker_settings_screen.dart | 30 +- lib/services/sensitive_prefs.dart | 3 +- .../mdblist/mdblist_auth_service.dart | 163 +++++++++ .../trackers/mdblist/mdblist_client.dart | 159 +++++++++ .../trackers/mdblist/mdblist_constants.dart | 56 +++ .../trackers/mdblist/mdblist_tracker.dart | 325 ++++++++++++++++++ .../trackers/tracker_account_store.dart | 1 + lib/services/trackers/tracker_constants.dart | 2 +- .../trackers/tracker_coordinator.dart | 10 +- lib/services/trackers/tracker_session.dart | 10 + lib/widgets/catalog_source_logo.dart | 1 + pubspec.yaml | 1 + .../profile_session_screen_test.dart | 4 +- test/providers/trackers_provider_test.dart | 31 +- .../trackers_provider_trakt_test.dart | 2 +- .../settings/settings_screen_test.dart | 4 +- test/services/trackers/mdblist_auth_test.dart | 201 +++++++++++ .../trackers/mdblist_tracker_test.dart | 240 +++++++++++++ 46 files changed, 1343 insertions(+), 46 deletions(-) create mode 100644 assets/mdblist_mark.svg create mode 100644 lib/services/trackers/mdblist/mdblist_auth_service.dart create mode 100644 lib/services/trackers/mdblist/mdblist_client.dart create mode 100644 lib/services/trackers/mdblist/mdblist_constants.dart create mode 100644 lib/services/trackers/mdblist/mdblist_tracker.dart create mode 100644 test/services/trackers/mdblist_auth_test.dart create mode 100644 test/services/trackers/mdblist_tracker_test.dart diff --git a/assets/mdblist_mark.svg b/assets/mdblist_mark.svg new file mode 100644 index 00000000..f0dfcfe5 --- /dev/null +++ b/assets/mdblist_mark.svg @@ -0,0 +1 @@ + diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 52925cfe..79dce66b 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Plezy-ni ${service} üzərində aktivləşdirin", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index b891116d..13d0c7c1 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Активиране на Plezy в ${service}", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 7b13da37..013a80fd 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Aktiver Plezy på ${service}", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index fd02e1bf..e40b00e6 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Plezy auf ${service} aktivieren", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index d66b40e5..c5bcfdb1 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "MDBList" }, "deviceCode": { "title": "Activate Plezy on ${service}", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index e581bf61..e8f8d11a 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Activar Plezy en ${service}", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 51b5ee53..9f3b0c48 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Activer Plezy sur ${service}", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 099a8569..132b1ca9 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Plezy aktiválása a következőn: ${service}", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index c3a07485..e2e21996 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Attiva Plezy su ${service}", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index b8fc6caf..8e021c89 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1834,7 +1834,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "${service} で Plezy を有効化", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 8f634355..c30420d6 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Plezy-ді ${service} қызметінде белсендіру", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 18ba9b36..ee2d7870 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1834,7 +1834,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "${service}에서 Plezy 활성화", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index b550b228..b5f1246f 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Aktiver Plezy på ${service}", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 38618aad..d7df7c1c 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Plezy activeren op ${service}", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 40896f89..5fdb94c2 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1852,7 +1852,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Aktywuj Plezy w ${service}", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index ce5b4b7b..0cdec741 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Ativar o Plezy no ${service}", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 4d8d399d..7529b9d4 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1852,7 +1852,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Активируйте Plezy в ${service}", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 906445ed..597d9896 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32736 (1488 per locale) +/// Strings: 32737 (1488 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 732842fe..73308506 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -5928,6 +5928,9 @@ class Translations$services$names$en { /// en: 'Seerr' String get seerr => 'Seerr'; + + /// en: 'MDBList' + String get mdblist => 'MDBList'; } // Path: services.deviceCode @@ -7721,6 +7724,7 @@ extension on Translations { 'services.names.anilist' => 'AniList', 'services.names.simkl' => 'Simkl', 'services.names.seerr' => 'Seerr', + 'services.names.mdblist' => 'MDBList', 'services.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index e22ab0dc..bef4f5eb 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Aktivera Plezy på ${service}", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 5ecdc84e..9ddba236 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Plezy'yi ${service} üzerinde etkinleştirin", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 12e27649..5c021374 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -1840,7 +1840,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "Plezy-ni ${service} xizmatida faollashtiring", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 37a36730..d3be3e2c 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1834,7 +1834,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "在 ${service} 啟用 Plezy", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 6d791645..bffe47cf 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1834,7 +1834,8 @@ "mal": "MyAnimeList", "anilist": "AniList", "simkl": "Simkl", - "seerr": "Seerr" + "seerr": "Seerr", + "mdblist": "" }, "deviceCode": { "title": "在 ${service} 上激活 Plezy", diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart index 8ee5ee57..2fbebd49 100644 --- a/lib/models/catalog/catalog_item.dart +++ b/lib/models/catalog/catalog_item.dart @@ -7,7 +7,7 @@ import 'catalog_cast_member.dart'; import 'catalog_metadata.dart'; /// External catalog providers that can back the Explore tab. -enum CatalogSourceId { plex, trakt, mal, anilist, simkl, seerr } +enum CatalogSourceId { plex, trakt, mal, anilist, simkl, seerr, mdblist } /// Normalized airing/production status across providers (Trakt `status`, /// MAL `status`). Null when unknown or uninteresting (released movies). diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index cefaef4f..b7cb30e9 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -10,6 +10,9 @@ import '../services/trackers/anilist/anilist_tracker.dart'; import '../services/trackers/mal/mal_auth_service.dart'; import '../services/trackers/mal/mal_client.dart'; import '../services/trackers/mal/mal_tracker.dart'; +import '../services/trackers/mdblist/mdblist_auth_service.dart'; +import '../services/trackers/mdblist/mdblist_client.dart'; +import '../services/trackers/mdblist/mdblist_tracker.dart'; import '../services/trackers/oauth_proxy_client.dart'; import '../services/trackers/simkl/simkl_auth_service.dart'; import '../services/trackers/simkl/simkl_client.dart'; @@ -35,10 +38,10 @@ typedef TrackerSessionConnectPipeline = required void Function(TrackerSession enriched) assign, }); -/// Owns the active MAL / AniList / Simkl / Trakt sessions for the +/// Owns the active MAL / AniList / Simkl / Trakt / MDBList sessions for the /// currently-selected Plex profile. Single rebind seam: -/// [onActiveProfileChanged] loads all four sessions from their stores and -/// pushes them to their trackers. +/// [onActiveProfileChanged] loads every session from its store and pushes it +/// to the matching tracker. class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin { /// [httpClientFactory] must return a fresh client for each eager auth owner. /// Every returned client is closed when this provider is disposed. @@ -62,13 +65,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin ? AnilistAuthService() : AnilistAuthService(proxy: OAuthProxyClient(httpClient: httpClientFactory())), _simklAuth = httpClientFactory == null ? SimklAuthService() : SimklAuthService(httpClient: httpClientFactory()), - _traktAuth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory()); + _traktAuth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory()), + _mdblistAuth = httpClientFactory == null + ? MdblistAuthService() + : MdblistAuthService(httpClient: httpClientFactory()); final TrackerSessionConnectPipeline _connectPipeline; final MalAuthService _malAuth; final AnilistAuthService _anilistAuth; final SimklAuthService _simklAuth; final TraktAuthService _traktAuth; + final MdblistAuthService _mdblistAuth; final _TrackerSlot _mal = _TrackerSlot( TrackerService.mal, @@ -90,7 +97,15 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin (session, {required onInvalidated, onUpdated}) => TraktTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: onUpdated), ); - late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl, _trakt]; + final _TrackerSlot _mdblist = _TrackerSlot( + TrackerService.mdblist, + (session, {required onInvalidated, onUpdated}) => MdblistTracker.instance.rebindSession( + session, + onSessionInvalidated: onInvalidated, + onSessionUpdated: onUpdated, + ), + ); + late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl, _trakt, _mdblist]; String _activeUserUuid = ''; int _profileBindingGeneration = 0; @@ -102,11 +117,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin TrackerSession? get anilist => _anilist.session; TrackerSession? get simkl => _simkl.session; TrackerSession? get trakt => _trakt.session; + TrackerSession? get mdblist => _mdblist.session; bool get isMalConnected => _mal.session != null; bool get isAnilistConnected => _anilist.session != null; bool get isSimklConnected => _simkl.session != null; bool get isTraktConnected => _trakt.session != null; + bool get isMdblistConnected => _mdblist.session != null; /// The live MAL client for the Explore catalog, shared with the scrobble /// tracker so both ride one session (MAL rotates refresh tokens — a second @@ -132,6 +149,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin String? get anilistUsername => _anilist.session?.username; String? get simklUsername => _simkl.session?.username; String? get traktUsername => _trakt.session?.username; + String? get mdblistUsername => _mdblist.session?.username; bool isConnecting(TrackerService service) => _connecting == service; @@ -245,6 +263,34 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } + Future connectMdblist({required void Function(DeviceCode code) onCodeReady}) => _runConnect( + _mdblist, + authorize: () => _mdblistAuth.authorize( + onCodeReady: onCodeReady, + shouldCancel: _isConnectCancelled, + onCancel: _cancelCompleter!.future, + ), + enrich: _enrichMdblist, + ); + + /// Like Trakt, MDBList can revoke its token server-side. Local state goes + /// first, so a failed revoke still leaves the user disconnected here — the + /// token just stays valid on MDBList's side until it expires. + Future disconnectMdblist() async { + final session = _mdblist.session; + await _clearAndRebind(_mdblist); + if (session == null) return; + + // `revoke` logs and swallows its own failures; disposal is the only thing + // this caller still owns. + final client = MdblistClient(session, onSessionInvalidated: () {}); + try { + await client.revoke(); + } finally { + client.dispose(); + } + } + bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false; Future _runConnect( @@ -352,6 +398,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin fetchUsername: (client) async => (await client.getUserSettings()).username, ); + Future _enrichMdblist(TrackerSession raw) => enrichTrackerSessionUsername( + session: raw, + failureMessage: 'MDBList: getUser failed (non-fatal)', + createClient: () => MdblistClient(raw, onSessionInvalidated: () {}), + fetchUsername: (client) async => (await client.getUser())?['username'] as String?, + ); + /// Push a slot's session to its tracker, snapshotting the active profile and /// bumping the slot's rebind generation first. Bumping here is what lets a /// stale client callback — or a racing profile load — detect that it has been @@ -386,6 +439,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin _anilistAuth.dispose(); _simklAuth.dispose(); _traktAuth.dispose(); + _mdblistAuth.dispose(); super.dispose(); } } diff --git a/lib/screens/settings/tracker_service_info.dart b/lib/screens/settings/tracker_service_info.dart index 250a0e57..dde8d0f7 100644 --- a/lib/screens/settings/tracker_service_info.dart +++ b/lib/screens/settings/tracker_service_info.dart @@ -6,6 +6,7 @@ import '../../models/catalog/catalog_item.dart'; import '../../providers/trackers_provider.dart'; import '../../services/trackers/anilist/anilist_tracker.dart'; import '../../services/trackers/mal/mal_tracker.dart'; +import '../../services/trackers/mdblist/mdblist_tracker.dart'; import '../../services/trackers/simkl/simkl_tracker.dart'; import '../../services/trackers/tracker.dart'; import '../../services/trackers/tracker_constants.dart'; @@ -88,5 +89,11 @@ class TrackerServiceInfo { ratingSource: SimklTracker.instance, startConnection: startSimklConnection, ), + TrackerServiceInfo.shared( + TrackerConfig.mdblist(), + logoSource: CatalogSourceId.mdblist, + ratingSource: MdblistTracker.instance, + startConnection: startMdblistConnection, + ), ]; } diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart index 659f3b15..8c9b2e78 100644 --- a/lib/screens/settings/tracker_settings_screen.dart +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -7,6 +7,7 @@ import '../../models/trackers/device_code.dart'; import '../../providers/trackers_provider.dart'; import '../../services/trackers/anilist/anilist_tracker.dart'; import '../../services/trackers/mal/mal_tracker.dart'; +import '../../services/trackers/mdblist/mdblist_tracker.dart'; import '../../services/trackers/oauth_proxy_client.dart'; import '../../services/trackers/simkl/simkl_tracker.dart'; import '../../services/trackers/tracker_constants.dart'; @@ -60,6 +61,20 @@ Future startSimklConnection(BuildContext context) { ); } +Future startMdblistConnection(BuildContext context) { + final account = context.read(); + final name = t.services.names.mdblist; + return launchTrackerConnect( + context, + isBusyOrConnected: account.isConnecting(TrackerService.mdblist) || account.isMdblistConnected, + serviceName: name, + connect: (cb) => account.connectMdblist(onCodeReady: cb), + onCancel: account.cancelConnect, + buildDialog: (p, cancel) => DeviceCodeDialog(code: p, serviceName: name, onCancel: cancel), + urlFor: (p) => p.verificationUrlComplete ?? p.verificationUrl, + ); +} + /// Per-service wiring for [TrackerSettingsScreen]. Keeps tracker-specific /// method names out of the shared screen body. class TrackerConfig { @@ -107,11 +122,20 @@ class TrackerConfig { onScrobbleChanged: SimklTracker.instance.setEnabled, disconnect: (a) => a.disconnectSimkl(), ); + + static TrackerConfig mdblist() => TrackerConfig( + service: TrackerService.mdblist, + displayName: t.services.names.mdblist, + isConnected: (a) => a.isMdblistConnected, + username: (a) => a.mdblistUsername, + onScrobbleChanged: MdblistTracker.instance.setEnabled, + disconnect: (a) => a.disconnectMdblist(), + ); } -/// Shared settings screen for MAL, AniList, and Simkl. Only reachable while -/// connected — if the session drops (refresh failure, back-nav race) we pop -/// back to the hub. +/// Shared settings screen for MAL, AniList, Simkl and MDBList. Only reachable +/// while connected — if the session drops (refresh failure, back-nav race) we +/// pop back to the hub. class TrackerSettingsScreen extends StatelessWidget { final TrackerConfig config; const TrackerSettingsScreen({super.key, required this.config}); diff --git a/lib/services/sensitive_prefs.dart b/lib/services/sensitive_prefs.dart index b39d0713..7ba555f9 100644 --- a/lib/services/sensitive_prefs.dart +++ b/lib/services/sensitive_prefs.dart @@ -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 trackerSessionBaseKeys = [ 'anilist_session', 'simkl_session', 'trakt_session', + 'mdblist_session', ]; /// Unscoped base key used by `SeerrSessionStore`. diff --git a/lib/services/trackers/mdblist/mdblist_auth_service.dart b/lib/services/trackers/mdblist/mdblist_auth_service.dart new file mode 100644 index 00000000..f61662d7 --- /dev/null +++ b/lib/services/trackers/mdblist/mdblist_auth_service.dart @@ -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 _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 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; + 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 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 _decodeBody(String body) { + try { + final decoded = json.decode(body); + return decoded is Map ? decoded : const {}; + } catch (_) { + return const {}; + } + } + + @override + TrackerSession buildSession(Map 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 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, + ); + return fresh.copyWith(username: current.username); + } +} diff --git a/lib/services/trackers/mdblist/mdblist_client.dart b/lib/services/trackers/mdblist/mdblist_client.dart new file mode 100644 index 00000000..b39be9f4 --- /dev/null +++ b/lib/services/trackers/mdblist/mdblist_client.dart @@ -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 _defaultAllowedStatuses = {200, 201, 204}; + + TrackerSession _session; + final TrackerHttpClient _http; + final MdblistAuthService _auth; + final void Function() onSessionInvalidated; + final void Function(TrackerSession)? onSessionUpdated; + + final _refreshCoalescer = FutureCoalescer(); + + 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?> getUser() async { + final res = await _request('GET', '/user'); + return res is Map ? res.cast() : null; + } + + /// Mark items watched. Body shape: + /// ``` + /// {"movies": [{"ids": {"imdb": "tt0372784"}, "watched_at": "..."}]} + /// ``` + Future addToWatched(Map body) => _request('POST', '/sync/watched', body: body); + + Future removeFromWatched(Map body) => _request('POST', '/sync/watched/remove', body: body); + + /// Report real-time playback. [action] is `start`, `pause` or `stop`. + Future scrobble(String action, Map body) => _request('POST', '/scrobble/$action', body: body); + + Future addRatings(Map body) => _request('POST', '/sync/ratings', body: body); + + Future removeRatings(Map 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> getRatings(String type) async { + final res = await _request('GET', '/sync/ratings'); + if (res is Map && res[type] is List) return res[type] as List; + 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 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 _refresh() => _refreshCoalescer.run(_doRefresh); + + Future _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 _request( + String method, + String path, { + Map? body, + Set 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 _send(String method, String path, {Map? 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'}, + ); + } +} diff --git a/lib/services/trackers/mdblist/mdblist_constants.dart b/lib/services/trackers/mdblist/mdblist_constants.dart new file mode 100644 index 00000000..d74a1586 --- /dev/null +++ b/lib/services/trackers/mdblist/mdblist_constants.dart @@ -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 headers({String? accessToken}) => { + 'Accept': 'application/json', + 'User-Agent': '$appName/$appVersion', + if (accessToken != null) 'Authorization': 'Bearer $accessToken', + }; +} diff --git a/lib/services/trackers/mdblist/mdblist_tracker.dart b/lib/services/trackers/mdblist/mdblist_tracker.dart new file mode 100644 index 00000000..0e7b08ec --- /dev/null +++ b/lib/services/trackers/mdblist/mdblist_tracker.dart @@ -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 + 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 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 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 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 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? _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? _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) _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 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(); + 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 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 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 entry, Map 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 _ratingBody(TrackerRatingContext ctx, Map 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 _ids(ExternalIds external) => {'imdb': ?external.imdb, 'tmdb': ?external.tmdb}; +} diff --git a/lib/services/trackers/tracker_account_store.dart b/lib/services/trackers/tracker_account_store.dart index 65aaa3e8..c6d11c5a 100644 --- a/lib/services/trackers/tracker_account_store.dart +++ b/lib/services/trackers/tracker_account_store.dart @@ -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]!; diff --git a/lib/services/trackers/tracker_constants.dart b/lib/services/trackers/tracker_constants.dart index ec73a460..c2ac2568 100644 --- a/lib/services/trackers/tracker_constants.dart +++ b/lib/services/trackers/tracker_constants.dart @@ -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 } diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index bdee96e4..fb0a9f1a 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -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 diff --git a/lib/services/trackers/tracker_session.dart b/lib/services/trackers/tracker_session.dart index e105e59e..dddaa571 100644 --- a/lib/services/trackers/tracker_session.dart +++ b/lib/services/trackers/tracker_session.dart @@ -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?), diff --git a/lib/widgets/catalog_source_logo.dart b/lib/widgets/catalog_source_logo.dart index f3b0044e..3b10e98a 100644 --- a/lib/widgets/catalog_source_logo.dart +++ b/lib/widgets/catalog_source_logo.dart @@ -24,6 +24,7 @@ class CatalogSourceLogo extends StatelessWidget { CatalogSourceId.anilist => 'assets/anilist_mark.svg', CatalogSourceId.simkl => 'assets/simkl_mark.svg', CatalogSourceId.seerr => 'assets/seerr_mark.svg', + CatalogSourceId.mdblist => 'assets/mdblist_mark.svg', }; final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; return SvgPicture.asset( diff --git a/pubspec.yaml b/pubspec.yaml index a0f7683e..93dafca8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -154,6 +154,7 @@ flutter: - assets/anilist_mark.svg - assets/simkl_mark.svg - assets/seerr_mark.svg + - assets/mdblist_mark.svg - assets/go-noto-current-regular.ttf - assets/shaders/nvscaler/ - assets/shaders/artcnn/ diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart index bb1e5ec3..673a17ff 100644 --- a/test/navigation/profile_session_screen_test.dart +++ b/test/navigation/profile_session_screen_test.dart @@ -63,9 +63,9 @@ void main() { final companionProviders = []; final disposedActiveIds = []; final trackerHttpClients = []; - // 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 []); trackerHttpClients.add(client); diff --git a/test/providers/trackers_provider_test.dart b/test/providers/trackers_provider_test.dart index e072fa74..ba7539dd 100644 --- a/test/providers/trackers_provider_test.dart +++ b/test/providers/trackers_provider_test.dart @@ -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 _connect(TrackersProvider provider, TrackerService service) => switch (service) { @@ -460,6 +481,7 @@ Future _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 { diff --git a/test/providers/trackers_provider_trakt_test.dart b/test/providers/trackers_provider_trakt_test.dart index ba7d9afc..163889c6 100644 --- a/test/providers/trackers_provider_trakt_test.dart +++ b/test/providers/trackers_provider_trakt_test.dart @@ -56,7 +56,7 @@ void main() { }, ); - expect(clients, hasLength(5)); + expect(clients, hasLength(6)); for (final client in clients) { expect(client.closeCount, 0); } diff --git a/test/screens/settings/settings_screen_test.dart b/test/screens/settings/settings_screen_test.dart index 0e28e559..4cb0c88b 100644 --- a/test/screens/settings/settings_screen_test.dart +++ b/test/screens/settings/settings_screen_test.dart @@ -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); } diff --git a/test/services/trackers/mdblist_auth_test.dart b/test/services/trackers/mdblist_auth_test.dart new file mode 100644 index 00000000..a935adb9 --- /dev/null +++ b/test/services/trackers/mdblist_auth_test.dart @@ -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())); + }); + }); + + group('probe', () { + Future 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()); + }); + + test('maps slow_down to a backoff', () async { + expect(await probeWith(400, '{"error": "slow_down"}'), isA()); + }); + + test('maps access_denied to denied', () async { + expect(await probeWith(400, '{"error": "access_denied"}'), isA()); + }); + + test('maps expired_token to expired', () async { + expect(await probeWith(400, '{"error": "expired_token"}'), isA()); + }); + + test('treats device_not_found as expired', () async { + expect(await probeWith(404, '{"error": "device_not_found"}'), isA()); + }); + + 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()); + expect((event as DevicePollSuccess).tokenResponse['access_token'], 'at'); + }); + + test('keeps polling through an unparseable error page', () async { + expect(await probeWith(502, 'bad gateway'), isA()); + }); + + 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()); + }); + }); + + 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().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().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(); +} diff --git a/test/services/trackers/mdblist_tracker_test.dart b/test/services/trackers/mdblist_tracker_test.dart new file mode 100644 index 00000000..c38bd33d --- /dev/null +++ b/test/services/trackers/mdblist_tracker_test.dart @@ -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 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)); + 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; + final episode = ((season['seasons'] as List).single as Map)['episodes'] as List; + expect((episode.single as Map).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()), + ); + }); + }); +}