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:
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M1.928.029A2.47 2.47 0 0 0 .093 1.673c-.085.248-.09.629-.09 10.33s.005 10.08.09 10.33a2.51 2.51 0 0 0 1.512 1.558l.276.108h20.237l.277-.108a2.51 2.51 0 0 0 1.512-1.559c.085-.25.09-.63.09-10.33s-.005-10.08-.09-10.33A2.51 2.51 0 0 0 22.395.115l-.277-.109L12.117 0C6.615-.004 2.032.011 1.929.029m7.48 8.067 2.123 2.004v1.54c0 .897-.02 1.536-.043 1.527s-.92-.845-1.995-1.86c-1.071-1.01-1.962-1.84-1.977-1.84s-.024 1.91-.024 4.248v4.25H4.911V6.085h1.188l1.183.006zm9.729 3.93v5.94h-2.63l-.01-4.25-.013-4.25-1.907 1.795a367 367 0 0 1-1.98 1.864c-.076.056-.08-.047-.08-1.489v-1.555l2.127-1.995 2.122-1.995 1.187-.005h1.184z"/></svg>
|
||||||
|
After Width: | Height: | Size: 715 B |
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Plezy-ni ${service} üzərində aktivləşdirin",
|
"title": "Plezy-ni ${service} üzərində aktivləşdirin",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Активиране на Plezy в ${service}",
|
"title": "Активиране на Plezy в ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Aktiver Plezy på ${service}",
|
"title": "Aktiver Plezy på ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Plezy auf ${service} aktivieren",
|
"title": "Plezy auf ${service} aktivieren",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": "MDBList"
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Activate Plezy on ${service}",
|
"title": "Activate Plezy on ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Activar Plezy en ${service}",
|
"title": "Activar Plezy en ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Activer Plezy sur ${service}",
|
"title": "Activer Plezy sur ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Plezy aktiválása a következőn: ${service}",
|
"title": "Plezy aktiválása a következőn: ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Attiva Plezy su ${service}",
|
"title": "Attiva Plezy su ${service}",
|
||||||
|
|||||||
@@ -1834,7 +1834,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "${service} で Plezy を有効化",
|
"title": "${service} で Plezy を有効化",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Plezy-ді ${service} қызметінде белсендіру",
|
"title": "Plezy-ді ${service} қызметінде белсендіру",
|
||||||
|
|||||||
@@ -1834,7 +1834,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "${service}에서 Plezy 활성화",
|
"title": "${service}에서 Plezy 활성화",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Aktiver Plezy på ${service}",
|
"title": "Aktiver Plezy på ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Plezy activeren op ${service}",
|
"title": "Plezy activeren op ${service}",
|
||||||
|
|||||||
@@ -1852,7 +1852,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Aktywuj Plezy w ${service}",
|
"title": "Aktywuj Plezy w ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Ativar o Plezy no ${service}",
|
"title": "Ativar o Plezy no ${service}",
|
||||||
|
|||||||
@@ -1852,7 +1852,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Активируйте Plezy в ${service}",
|
"title": "Активируйте Plezy в ${service}",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 22
|
/// Locales: 22
|
||||||
/// Strings: 32736 (1488 per locale)
|
/// Strings: 32737 (1488 per locale)
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|||||||
@@ -5928,6 +5928,9 @@ class Translations$services$names$en {
|
|||||||
|
|
||||||
/// en: 'Seerr'
|
/// en: 'Seerr'
|
||||||
String get seerr => 'Seerr';
|
String get seerr => 'Seerr';
|
||||||
|
|
||||||
|
/// en: 'MDBList'
|
||||||
|
String get mdblist => 'MDBList';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: services.deviceCode
|
// Path: services.deviceCode
|
||||||
@@ -7721,6 +7724,7 @@ extension on Translations {
|
|||||||
'services.names.anilist' => 'AniList',
|
'services.names.anilist' => 'AniList',
|
||||||
'services.names.simkl' => 'Simkl',
|
'services.names.simkl' => 'Simkl',
|
||||||
'services.names.seerr' => 'Seerr',
|
'services.names.seerr' => 'Seerr',
|
||||||
|
'services.names.mdblist' => 'MDBList',
|
||||||
'services.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}',
|
'services.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}',
|
||||||
'services.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:',
|
'services.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:',
|
||||||
'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate',
|
'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate',
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Aktivera Plezy på ${service}",
|
"title": "Aktivera Plezy på ${service}",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Plezy'yi ${service} üzerinde etkinleştirin",
|
"title": "Plezy'yi ${service} üzerinde etkinleştirin",
|
||||||
|
|||||||
@@ -1840,7 +1840,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "Plezy-ni ${service} xizmatida faollashtiring",
|
"title": "Plezy-ni ${service} xizmatida faollashtiring",
|
||||||
|
|||||||
@@ -1834,7 +1834,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "在 ${service} 啟用 Plezy",
|
"title": "在 ${service} 啟用 Plezy",
|
||||||
|
|||||||
@@ -1834,7 +1834,8 @@
|
|||||||
"mal": "MyAnimeList",
|
"mal": "MyAnimeList",
|
||||||
"anilist": "AniList",
|
"anilist": "AniList",
|
||||||
"simkl": "Simkl",
|
"simkl": "Simkl",
|
||||||
"seerr": "Seerr"
|
"seerr": "Seerr",
|
||||||
|
"mdblist": ""
|
||||||
},
|
},
|
||||||
"deviceCode": {
|
"deviceCode": {
|
||||||
"title": "在 ${service} 上激活 Plezy",
|
"title": "在 ${service} 上激活 Plezy",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'catalog_cast_member.dart';
|
|||||||
import 'catalog_metadata.dart';
|
import 'catalog_metadata.dart';
|
||||||
|
|
||||||
/// External catalog providers that can back the Explore tab.
|
/// 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`,
|
/// Normalized airing/production status across providers (Trakt `status`,
|
||||||
/// MAL `status`). Null when unknown or uninteresting (released movies).
|
/// MAL `status`). Null when unknown or uninteresting (released movies).
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import '../services/trackers/anilist/anilist_tracker.dart';
|
|||||||
import '../services/trackers/mal/mal_auth_service.dart';
|
import '../services/trackers/mal/mal_auth_service.dart';
|
||||||
import '../services/trackers/mal/mal_client.dart';
|
import '../services/trackers/mal/mal_client.dart';
|
||||||
import '../services/trackers/mal/mal_tracker.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/oauth_proxy_client.dart';
|
||||||
import '../services/trackers/simkl/simkl_auth_service.dart';
|
import '../services/trackers/simkl/simkl_auth_service.dart';
|
||||||
import '../services/trackers/simkl/simkl_client.dart';
|
import '../services/trackers/simkl/simkl_client.dart';
|
||||||
@@ -35,10 +38,10 @@ typedef TrackerSessionConnectPipeline =
|
|||||||
required void Function(TrackerSession enriched) assign,
|
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:
|
/// currently-selected Plex profile. Single rebind seam:
|
||||||
/// [onActiveProfileChanged] loads all four sessions from their stores and
|
/// [onActiveProfileChanged] loads every session from its store and pushes it
|
||||||
/// pushes them to their trackers.
|
/// to the matching tracker.
|
||||||
class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
||||||
/// [httpClientFactory] must return a fresh client for each eager auth owner.
|
/// [httpClientFactory] must return a fresh client for each eager auth owner.
|
||||||
/// Every returned client is closed when this provider is disposed.
|
/// Every returned client is closed when this provider is disposed.
|
||||||
@@ -62,13 +65,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
? AnilistAuthService()
|
? AnilistAuthService()
|
||||||
: AnilistAuthService(proxy: OAuthProxyClient(httpClient: httpClientFactory())),
|
: AnilistAuthService(proxy: OAuthProxyClient(httpClient: httpClientFactory())),
|
||||||
_simklAuth = httpClientFactory == null ? SimklAuthService() : SimklAuthService(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 TrackerSessionConnectPipeline _connectPipeline;
|
||||||
final MalAuthService _malAuth;
|
final MalAuthService _malAuth;
|
||||||
final AnilistAuthService _anilistAuth;
|
final AnilistAuthService _anilistAuth;
|
||||||
final SimklAuthService _simklAuth;
|
final SimklAuthService _simklAuth;
|
||||||
final TraktAuthService _traktAuth;
|
final TraktAuthService _traktAuth;
|
||||||
|
final MdblistAuthService _mdblistAuth;
|
||||||
|
|
||||||
final _TrackerSlot _mal = _TrackerSlot(
|
final _TrackerSlot _mal = _TrackerSlot(
|
||||||
TrackerService.mal,
|
TrackerService.mal,
|
||||||
@@ -90,7 +97,15 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
(session, {required onInvalidated, onUpdated}) =>
|
(session, {required onInvalidated, onUpdated}) =>
|
||||||
TraktTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: 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 = '';
|
String _activeUserUuid = '';
|
||||||
int _profileBindingGeneration = 0;
|
int _profileBindingGeneration = 0;
|
||||||
@@ -102,11 +117,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
TrackerSession? get anilist => _anilist.session;
|
TrackerSession? get anilist => _anilist.session;
|
||||||
TrackerSession? get simkl => _simkl.session;
|
TrackerSession? get simkl => _simkl.session;
|
||||||
TrackerSession? get trakt => _trakt.session;
|
TrackerSession? get trakt => _trakt.session;
|
||||||
|
TrackerSession? get mdblist => _mdblist.session;
|
||||||
|
|
||||||
bool get isMalConnected => _mal.session != null;
|
bool get isMalConnected => _mal.session != null;
|
||||||
bool get isAnilistConnected => _anilist.session != null;
|
bool get isAnilistConnected => _anilist.session != null;
|
||||||
bool get isSimklConnected => _simkl.session != null;
|
bool get isSimklConnected => _simkl.session != null;
|
||||||
bool get isTraktConnected => _trakt.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
|
/// The live MAL client for the Explore catalog, shared with the scrobble
|
||||||
/// tracker so both ride one session (MAL rotates refresh tokens — a second
|
/// 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 anilistUsername => _anilist.session?.username;
|
||||||
String? get simklUsername => _simkl.session?.username;
|
String? get simklUsername => _simkl.session?.username;
|
||||||
String? get traktUsername => _trakt.session?.username;
|
String? get traktUsername => _trakt.session?.username;
|
||||||
|
String? get mdblistUsername => _mdblist.session?.username;
|
||||||
|
|
||||||
bool isConnecting(TrackerService service) => _connecting == service;
|
bool isConnecting(TrackerService service) => _connecting == service;
|
||||||
|
|
||||||
@@ -245,6 +263,34 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> 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<void> 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;
|
bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false;
|
||||||
|
|
||||||
Future<bool> _runConnect(
|
Future<bool> _runConnect(
|
||||||
@@ -352,6 +398,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
fetchUsername: (client) async => (await client.getUserSettings()).username,
|
fetchUsername: (client) async => (await client.getUserSettings()).username,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<TrackerSession> _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
|
/// 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
|
/// 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
|
/// 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();
|
_anilistAuth.dispose();
|
||||||
_simklAuth.dispose();
|
_simklAuth.dispose();
|
||||||
_traktAuth.dispose();
|
_traktAuth.dispose();
|
||||||
|
_mdblistAuth.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../models/catalog/catalog_item.dart';
|
|||||||
import '../../providers/trackers_provider.dart';
|
import '../../providers/trackers_provider.dart';
|
||||||
import '../../services/trackers/anilist/anilist_tracker.dart';
|
import '../../services/trackers/anilist/anilist_tracker.dart';
|
||||||
import '../../services/trackers/mal/mal_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/simkl/simkl_tracker.dart';
|
||||||
import '../../services/trackers/tracker.dart';
|
import '../../services/trackers/tracker.dart';
|
||||||
import '../../services/trackers/tracker_constants.dart';
|
import '../../services/trackers/tracker_constants.dart';
|
||||||
@@ -88,5 +89,11 @@ class TrackerServiceInfo {
|
|||||||
ratingSource: SimklTracker.instance,
|
ratingSource: SimklTracker.instance,
|
||||||
startConnection: startSimklConnection,
|
startConnection: startSimklConnection,
|
||||||
),
|
),
|
||||||
|
TrackerServiceInfo.shared(
|
||||||
|
TrackerConfig.mdblist(),
|
||||||
|
logoSource: CatalogSourceId.mdblist,
|
||||||
|
ratingSource: MdblistTracker.instance,
|
||||||
|
startConnection: startMdblistConnection,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../models/trackers/device_code.dart';
|
|||||||
import '../../providers/trackers_provider.dart';
|
import '../../providers/trackers_provider.dart';
|
||||||
import '../../services/trackers/anilist/anilist_tracker.dart';
|
import '../../services/trackers/anilist/anilist_tracker.dart';
|
||||||
import '../../services/trackers/mal/mal_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/oauth_proxy_client.dart';
|
||||||
import '../../services/trackers/simkl/simkl_tracker.dart';
|
import '../../services/trackers/simkl/simkl_tracker.dart';
|
||||||
import '../../services/trackers/tracker_constants.dart';
|
import '../../services/trackers/tracker_constants.dart';
|
||||||
@@ -60,6 +61,20 @@ Future<void> startSimklConnection(BuildContext context) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> startMdblistConnection(BuildContext context) {
|
||||||
|
final account = context.read<TrackersProvider>();
|
||||||
|
final name = t.services.names.mdblist;
|
||||||
|
return launchTrackerConnect<DeviceCode>(
|
||||||
|
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
|
/// Per-service wiring for [TrackerSettingsScreen]. Keeps tracker-specific
|
||||||
/// method names out of the shared screen body.
|
/// method names out of the shared screen body.
|
||||||
class TrackerConfig {
|
class TrackerConfig {
|
||||||
@@ -107,11 +122,20 @@ class TrackerConfig {
|
|||||||
onScrobbleChanged: SimklTracker.instance.setEnabled,
|
onScrobbleChanged: SimklTracker.instance.setEnabled,
|
||||||
disconnect: (a) => a.disconnectSimkl(),
|
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
|
/// Shared settings screen for MAL, AniList, Simkl and MDBList. Only reachable
|
||||||
/// connected — if the session drops (refresh failure, back-nav race) we pop
|
/// while connected — if the session drops (refresh failure, back-nav race) we
|
||||||
/// back to the hub.
|
/// pop back to the hub.
|
||||||
class TrackerSettingsScreen extends StatelessWidget {
|
class TrackerSettingsScreen extends StatelessWidget {
|
||||||
final TrackerConfig config;
|
final TrackerConfig config;
|
||||||
const TrackerSettingsScreen({super.key, required this.config});
|
const TrackerSettingsScreen({super.key, required this.config});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
/// Losing it orphans every one of those ciphertexts permanently.
|
/// Losing it orphans every one of those ciphertexts permanently.
|
||||||
/// * tracker sessions — `TrackerAccountStore` persists `TrackerSession.encode()`
|
/// * tracker sessions — `TrackerAccountStore` persists `TrackerSession.encode()`
|
||||||
/// verbatim, so raw OAuth `access_token`/`refresh_token` pairs for MAL,
|
/// 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
|
/// * Seerr sessions — `SeerrSessionStore` persists a raw `connect.sid` cookie
|
||||||
/// alongside a vault-protected password.
|
/// alongside a vault-protected password.
|
||||||
/// * [legacyPlexTokenPref] — the pre-connection-registry Plex token slot. It is
|
/// * [legacyPlexTokenPref] — the pre-connection-registry Plex token slot. It is
|
||||||
@@ -44,6 +44,7 @@ const List<String> trackerSessionBaseKeys = <String>[
|
|||||||
'anilist_session',
|
'anilist_session',
|
||||||
'simkl_session',
|
'simkl_session',
|
||||||
'trakt_session',
|
'trakt_session',
|
||||||
|
'mdblist_session',
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Unscoped base key used by `SeerrSessionStore`.
|
/// Unscoped base key used by `SeerrSessionStore`.
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../models/trackers/device_code.dart';
|
||||||
|
import '../../../utils/abortable_http_request.dart';
|
||||||
|
import '../../../utils/app_logger.dart';
|
||||||
|
import '../device_code_auth_service.dart';
|
||||||
|
import '../tracker_constants.dart';
|
||||||
|
import '../tracker_exceptions.dart';
|
||||||
|
import '../tracker_session.dart';
|
||||||
|
import 'mdblist_constants.dart';
|
||||||
|
|
||||||
|
/// MDBList OAuth Device Authorization Grant (RFC 8628).
|
||||||
|
///
|
||||||
|
/// The user opens `mdblist.com/oauth/device/` on any second device and
|
||||||
|
/// approves the shown code; the app polls the token endpoint meanwhile. No
|
||||||
|
/// redirect URI, no local listener and no client secret are involved, so the
|
||||||
|
/// same flow works identically on TV, mobile and desktop.
|
||||||
|
///
|
||||||
|
/// Unlike Trakt, MDBList reports poll state in the JSON body rather than
|
||||||
|
/// through distinct status codes — `authorization_pending` and `slow_down`
|
||||||
|
/// both arrive as HTTP 400 — so [probe] switches on `error`, not the status.
|
||||||
|
class MdblistAuthService extends DeviceCodeAuthServiceBase {
|
||||||
|
/// MDBList returns these for a terminally-invalid grant (revoked or expired
|
||||||
|
/// refresh token); anything else (5xx, network) is transient and must not
|
||||||
|
/// log the user out.
|
||||||
|
static const Set<int> _permanentRefreshFailureStatuses = {400, 401, 403};
|
||||||
|
|
||||||
|
/// Fallbacks for a response that omits them. MDBList currently answers with
|
||||||
|
/// a 30-minute window and a 5-second interval.
|
||||||
|
static const int _defaultExpiresIn = 1800;
|
||||||
|
static const int _defaultInterval = 5;
|
||||||
|
|
||||||
|
MdblistAuthService({super.httpClient});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DeviceCode> createDeviceCode() async {
|
||||||
|
final uri = Uri.parse(MdblistConstants.deviceAuthorizationUrl);
|
||||||
|
final res = await sendAbortableHttpRequest(
|
||||||
|
httpClient,
|
||||||
|
'POST',
|
||||||
|
uri,
|
||||||
|
body: {'client_id': MdblistConstants.clientId, 'scope': MdblistConstants.scope},
|
||||||
|
timeout: TrackerConstants.authRequestTimeout,
|
||||||
|
operation: 'MDBList device code request',
|
||||||
|
);
|
||||||
|
appLogger.d('MDBList POST ${uri.path} → ${res.statusCode}');
|
||||||
|
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw DeviceCodeAuthFlowException('MDBList device code request failed: HTTP ${res.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||||
|
final userCode = body['user_code'] as String;
|
||||||
|
final verificationUrl = body['verification_uri'] as String? ?? MdblistConstants.verificationUrl;
|
||||||
|
return DeviceCode(
|
||||||
|
deviceCode: body['device_code'] as String,
|
||||||
|
userCode: userCode,
|
||||||
|
verificationUrl: verificationUrl,
|
||||||
|
// MDBList omits `verification_uri_complete`, but its device page seeds
|
||||||
|
// the form from `?user_code=`, so build the prefilled link ourselves and
|
||||||
|
// still prefer a server-supplied one if it ever starts sending it.
|
||||||
|
verificationUrlComplete:
|
||||||
|
body['verification_uri_complete'] as String? ??
|
||||||
|
MdblistConstants.verificationUrlFor(userCode, verificationUrl: verificationUrl),
|
||||||
|
expiresIn: (body['expires_in'] as num?)?.toInt() ?? _defaultExpiresIn,
|
||||||
|
interval: (body['interval'] as num?)?.toInt() ?? _defaultInterval,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DevicePollEvent> probe(DeviceCode code) async {
|
||||||
|
final tokenUri = Uri.parse(MdblistConstants.tokenUrl);
|
||||||
|
final http.Response res;
|
||||||
|
try {
|
||||||
|
res = await sendAbortableHttpRequest(
|
||||||
|
httpClient,
|
||||||
|
'POST',
|
||||||
|
tokenUri,
|
||||||
|
body: {
|
||||||
|
'grant_type': MdblistConstants.deviceCodeGrantType,
|
||||||
|
'device_code': code.deviceCode,
|
||||||
|
'client_id': MdblistConstants.clientId,
|
||||||
|
},
|
||||||
|
timeout: TrackerConstants.authRequestTimeout,
|
||||||
|
operation: 'MDBList device token poll',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('MDBList device-code poll error (transient)', error: e);
|
||||||
|
return const DevicePollPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = _decodeBody(res.body);
|
||||||
|
if (res.statusCode == 200 && body['access_token'] != null) {
|
||||||
|
return DevicePollSuccess(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (body['error']) {
|
||||||
|
'authorization_pending' => const DevicePollPending(),
|
||||||
|
'slow_down' => const DevicePollSlowDown(),
|
||||||
|
'access_denied' => const DevicePollDenied(),
|
||||||
|
// `device_not_found` is MDBList's own code for a device grant that no
|
||||||
|
// longer exists, which is terminal in the same way as an expiry.
|
||||||
|
'expired_token' || 'device_not_found' => const DevicePollExpired(),
|
||||||
|
final error => _unexpected(error, res.statusCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep polling on anything unrecognised: the deadline in the poll loop
|
||||||
|
/// still bounds the flow, and treating an unknown code as terminal would
|
||||||
|
/// abandon an authorization the user may be about to complete.
|
||||||
|
static DevicePollEvent _unexpected(Object? error, int statusCode) {
|
||||||
|
appLogger.w('MDBList device-code unexpected response (HTTP $statusCode, error=$error)');
|
||||||
|
return const DevicePollPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _decodeBody(String body) {
|
||||||
|
try {
|
||||||
|
final decoded = json.decode(body);
|
||||||
|
return decoded is Map<String, dynamic> ? decoded : const {};
|
||||||
|
} catch (_) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TrackerSession buildSession(Map<String, dynamic> tokenResponse) =>
|
||||||
|
TrackerSession.fromTokenResponse(TrackerService.mdblist, tokenResponse);
|
||||||
|
|
||||||
|
/// Exchange the refresh token for a fresh access token. Public client, so
|
||||||
|
/// no secret rides along — only the client ID.
|
||||||
|
Future<TrackerSession> refresh(TrackerSession current) async {
|
||||||
|
final res = await sendAbortableHttpRequest(
|
||||||
|
httpClient,
|
||||||
|
'POST',
|
||||||
|
Uri.parse(MdblistConstants.tokenUrl),
|
||||||
|
body: {
|
||||||
|
'grant_type': 'refresh_token',
|
||||||
|
'refresh_token': current.requireRefreshToken(TrackerService.mdblist),
|
||||||
|
'client_id': MdblistConstants.clientId,
|
||||||
|
},
|
||||||
|
timeout: TrackerConstants.refreshTimeout,
|
||||||
|
operation: 'MDBList token refresh',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
appLogger.w('MDBList: refresh failed (HTTP ${res.statusCode})');
|
||||||
|
throw TrackerAuthException(
|
||||||
|
service: TrackerService.mdblist,
|
||||||
|
message: 'Refresh failed: HTTP ${res.statusCode}',
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
isPermanent: _permanentRefreshFailureStatuses.contains(res.statusCode),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final fresh = TrackerSession.fromTokenResponse(
|
||||||
|
TrackerService.mdblist,
|
||||||
|
json.decode(res.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
return fresh.copyWith(username: current.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../utils/app_logger.dart';
|
||||||
|
import '../future_coalescer.dart';
|
||||||
|
import '../tracker.dart';
|
||||||
|
import '../tracker_constants.dart';
|
||||||
|
import '../tracker_exceptions.dart';
|
||||||
|
import '../tracker_http_client.dart';
|
||||||
|
import '../tracker_session.dart';
|
||||||
|
import 'mdblist_auth_service.dart';
|
||||||
|
import 'mdblist_constants.dart';
|
||||||
|
|
||||||
|
/// HTTP wrapper for the MDBList REST API.
|
||||||
|
///
|
||||||
|
/// Access tokens live 30 days and rotate through the refresh grant, so this
|
||||||
|
/// mirrors the MAL/Trakt shape: refresh shortly before expiry or on a 401,
|
||||||
|
/// with concurrent refreshes coalesced into one in-flight request.
|
||||||
|
class MdblistClient implements DisposableTrackerClient {
|
||||||
|
/// `/scrobble/*` answers 201 on success; the sync endpoints answer 200.
|
||||||
|
static const Set<int> _defaultAllowedStatuses = {200, 201, 204};
|
||||||
|
|
||||||
|
TrackerSession _session;
|
||||||
|
final TrackerHttpClient _http;
|
||||||
|
final MdblistAuthService _auth;
|
||||||
|
final void Function() onSessionInvalidated;
|
||||||
|
final void Function(TrackerSession)? onSessionUpdated;
|
||||||
|
|
||||||
|
final _refreshCoalescer = FutureCoalescer<TrackerSession>();
|
||||||
|
|
||||||
|
MdblistClient(
|
||||||
|
TrackerSession session, {
|
||||||
|
required this.onSessionInvalidated,
|
||||||
|
this.onSessionUpdated,
|
||||||
|
http.Client? httpClient,
|
||||||
|
MdblistAuthService? authService,
|
||||||
|
}) : _session = session,
|
||||||
|
_http = TrackerHttpClient(service: TrackerService.mdblist, logLabel: 'MDBList', httpClient: httpClient),
|
||||||
|
_auth = authService ?? MdblistAuthService();
|
||||||
|
|
||||||
|
TrackerSession get session => _session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_http.dispose();
|
||||||
|
_auth.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current account. Used to populate the display name.
|
||||||
|
Future<Map<String, dynamic>?> getUser() async {
|
||||||
|
final res = await _request('GET', '/user');
|
||||||
|
return res is Map ? res.cast<String, dynamic>() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark items watched. Body shape:
|
||||||
|
/// ```
|
||||||
|
/// {"movies": [{"ids": {"imdb": "tt0372784"}, "watched_at": "..."}]}
|
||||||
|
/// ```
|
||||||
|
Future<void> addToWatched(Map<String, dynamic> body) => _request('POST', '/sync/watched', body: body);
|
||||||
|
|
||||||
|
Future<void> removeFromWatched(Map<String, dynamic> body) => _request('POST', '/sync/watched/remove', body: body);
|
||||||
|
|
||||||
|
/// Report real-time playback. [action] is `start`, `pause` or `stop`.
|
||||||
|
Future<void> scrobble(String action, Map<String, dynamic> body) => _request('POST', '/scrobble/$action', body: body);
|
||||||
|
|
||||||
|
Future<void> addRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings', body: body);
|
||||||
|
|
||||||
|
Future<void> removeRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings/remove', body: body);
|
||||||
|
|
||||||
|
/// All of the user's ratings, keyed by `movies` / `shows` / `seasons` /
|
||||||
|
/// `episodes`. MDBList has no per-item rating lookup, so the caller filters.
|
||||||
|
Future<List<dynamic>> getRatings(String type) async {
|
||||||
|
final res = await _request('GET', '/sync/ratings');
|
||||||
|
if (res is Map && res[type] is List) return res[type] as List<dynamic>;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort server-side revoke, mirroring Trakt's disconnect. Failure is
|
||||||
|
/// non-fatal: the local session is already gone by the time this runs.
|
||||||
|
Future<void> revoke() async {
|
||||||
|
try {
|
||||||
|
await _http.sendForm(
|
||||||
|
'POST',
|
||||||
|
Uri.parse(MdblistConstants.revokeUrl),
|
||||||
|
headers: MdblistConstants.headers(),
|
||||||
|
body: {'token': _session.accessToken, 'client_id': MdblistConstants.clientId},
|
||||||
|
timeout: TrackerConstants.revokeTimeout,
|
||||||
|
operation: 'MDBList token revoke',
|
||||||
|
allowedMethods: const {'POST'},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('MDBList: revoke failed (non-fatal)', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TrackerSession> _refresh() => _refreshCoalescer.run(_doRefresh);
|
||||||
|
|
||||||
|
Future<TrackerSession> _doRefresh() async {
|
||||||
|
try {
|
||||||
|
final fresh = await _auth.refresh(_session);
|
||||||
|
_session = fresh;
|
||||||
|
onSessionUpdated?.call(fresh);
|
||||||
|
return fresh;
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('MDBList: refresh failed', error: e);
|
||||||
|
// Only a terminally-invalid grant clears the session; transient 5xx and
|
||||||
|
// network failures fall through so a later 401 can retry.
|
||||||
|
if (e is TrackerAuthException && e.isPermanent) onSessionInvalidated();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an authenticated request, refreshing on 401 and retrying once.
|
||||||
|
Future<dynamic> _request(
|
||||||
|
String method,
|
||||||
|
String path, {
|
||||||
|
Map<String, dynamic>? body,
|
||||||
|
Set<int> allowStatuses = _defaultAllowedStatuses,
|
||||||
|
}) async {
|
||||||
|
if (_session.needsRefresh) {
|
||||||
|
try {
|
||||||
|
await _refresh();
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through; the request will hit 401 naturally and retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = await _send(method, path, body: body);
|
||||||
|
|
||||||
|
if (res.statusCode == 401) {
|
||||||
|
// A failed refresh propagates its TrackerAuthException, matching Trakt.
|
||||||
|
await _refresh();
|
||||||
|
res = await _send(method, path, body: body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowStatuses.contains(res.statusCode)) return TrackerHttpClient.decodeJson(res.body);
|
||||||
|
|
||||||
|
if (res.statusCode == 429) {
|
||||||
|
throw TrackerRateLimitException(
|
||||||
|
service: TrackerService.mdblist,
|
||||||
|
retryAfterSeconds: int.tryParse(res.headers['retry-after'] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TrackerApiException(service: TrackerService.mdblist, statusCode: res.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<http.Response> _send(String method, String path, {Map<String, dynamic>? body}) {
|
||||||
|
final uri = Uri.parse('${MdblistConstants.apiBase}$path');
|
||||||
|
return _http.sendJson(
|
||||||
|
method,
|
||||||
|
uri,
|
||||||
|
headers: MdblistConstants.headers(accessToken: _session.accessToken),
|
||||||
|
body: body,
|
||||||
|
allowedMethods: const {'GET', 'POST'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/// Bundled MDBList API credentials and endpoints.
|
||||||
|
///
|
||||||
|
/// Registered as a **Device Code** app at https://mdblist.com/developer. That
|
||||||
|
/// app type takes no client secret and no redirect URI, so the public client
|
||||||
|
/// ID below is the only credential in the binary — unlike Trakt, there is no
|
||||||
|
/// extractable secret to worry about.
|
||||||
|
class MdblistConstants {
|
||||||
|
MdblistConstants._();
|
||||||
|
|
||||||
|
/// Registered MDBList Device Code app client ID. Public by design: the
|
||||||
|
/// device-code grant authenticates the user, not the binary.
|
||||||
|
static const String clientId = 'xOUwKUPdGEbHif6aKwW2gCxCAvFx7m0Q3jX0ZxXZ';
|
||||||
|
|
||||||
|
static const String apiBase = 'https://api.mdblist.com';
|
||||||
|
static const String webBase = 'https://mdblist.com';
|
||||||
|
|
||||||
|
static const String appName = 'plezy';
|
||||||
|
static const String appVersion = '2';
|
||||||
|
|
||||||
|
/// OAuth endpoints. MDBList runs django-oauth-toolkit, whose routes are
|
||||||
|
/// registered with a trailing slash — dropping it 404s the request.
|
||||||
|
static const String deviceAuthorizationUrl = '$apiBase/oauth/device-authorization/';
|
||||||
|
static const String tokenUrl = '$apiBase/oauth/token/';
|
||||||
|
static const String revokeUrl = '$apiBase/oauth/revoke_token/';
|
||||||
|
|
||||||
|
/// Page the user opens on a phone or laptop to approve the device.
|
||||||
|
static const String verificationUrl = '$webBase/oauth/device/';
|
||||||
|
|
||||||
|
static const String deviceCodeGrantType = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
|
||||||
|
/// The only scope MDBList offers. Grants the user's profile, lists,
|
||||||
|
/// watchlist, ratings, collection and playback state — there is no
|
||||||
|
/// read-only alternative to pick.
|
||||||
|
static const String scope = 'write';
|
||||||
|
|
||||||
|
/// Prefilled activation URL for [userCode].
|
||||||
|
///
|
||||||
|
/// MDBList does not return `verification_uri_complete`, but its device page
|
||||||
|
/// seeds the code field from a `user_code` query parameter and the sign-in
|
||||||
|
/// redirect preserves the query string. Building the URL here is what makes
|
||||||
|
/// the dialog's "open to activate" button land on a filled-in form instead
|
||||||
|
/// of an empty one. If the parameter is ever ignored the page still renders
|
||||||
|
/// normally, so this degrades to manual entry rather than breaking.
|
||||||
|
static String verificationUrlFor(String userCode, {String? verificationUrl}) => Uri.parse(
|
||||||
|
verificationUrl ?? MdblistConstants.verificationUrl,
|
||||||
|
).replace(queryParameters: {'user_code': userCode}).toString();
|
||||||
|
|
||||||
|
/// Headers for every MDBList API call. The token rides the `Authorization`
|
||||||
|
/// header; the `?apikey=` query form is deliberately unused so credentials
|
||||||
|
/// never enter a URL.
|
||||||
|
static Map<String, String> headers({String? accessToken}) => {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': '$appName/$appVersion',
|
||||||
|
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../media/media_kind.dart';
|
||||||
|
import '../../../models/trackers/tracker_context.dart';
|
||||||
|
import '../../../utils/app_logger.dart';
|
||||||
|
import '../../../utils/external_ids.dart';
|
||||||
|
import '../../../utils/json_utils.dart';
|
||||||
|
import '../tracker.dart';
|
||||||
|
import '../tracker_constants.dart';
|
||||||
|
import '../tracker_id_resolver.dart';
|
||||||
|
import '../tracker_rating_match.dart';
|
||||||
|
import '../tracker_session.dart';
|
||||||
|
import '../tracker_write_queue.dart';
|
||||||
|
import 'mdblist_client.dart';
|
||||||
|
|
||||||
|
/// MDBList tracker.
|
||||||
|
///
|
||||||
|
/// In-player playback is reported in real time through `POST /scrobble/start`,
|
||||||
|
/// `/pause` and `/stop`; MDBList's own rule then decides watched state — a
|
||||||
|
/// `stop` at or above 80% progress files the item under `/sync/watched` and
|
||||||
|
/// deletes the session. `POST /sync/watched` covers the marks that never pass
|
||||||
|
/// through the player: manual, container, offline replay and external players.
|
||||||
|
///
|
||||||
|
/// Matching is by IMDb and TMDb id only. MDBList's id block accepts
|
||||||
|
/// `imdb`/`tmdb`/`trakt`/`kitsu`/`mdblist` but **not** `tvdb`, so an item that
|
||||||
|
/// a media server only identifies by TVDB id cannot be written and is skipped
|
||||||
|
/// rather than mismatched onto the wrong title.
|
||||||
|
class MdblistTracker extends TrackerBase
|
||||||
|
with ClientBackedTracker<MdblistClient>
|
||||||
|
implements TrackerRatingSource, RealtimeScrobbleTracker, EpisodeHistoryTracker {
|
||||||
|
static MdblistTracker? _instance;
|
||||||
|
static MdblistTracker get instance => _instance ??= MdblistTracker._();
|
||||||
|
MdblistTracker._();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'mdblist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
TrackerService get service => TrackerService.mdblist;
|
||||||
|
|
||||||
|
/// MDBList carries no anime mapping of its own and takes plain external ids.
|
||||||
|
@override
|
||||||
|
bool get needsFribb => false;
|
||||||
|
|
||||||
|
/// MDBList counts a `/scrobble/stop` as a watch from this progress upwards.
|
||||||
|
static const double _scrobbleWatchedPercent = 80.0;
|
||||||
|
|
||||||
|
/// The bound client is replaced on every session rebind, so its identity is
|
||||||
|
/// the account identity.
|
||||||
|
@override
|
||||||
|
Object? get scrobbleBinding => client;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get canReportPlayback => isEnabledWithSession;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScrobblePolicy get scrobblePolicy => const ScrobblePolicy(
|
||||||
|
// MDBList documents no per-item scrobble cooldown, so this mirrors the
|
||||||
|
// conservative Trakt window rather than re-sending `start` freely.
|
||||||
|
resendThrottle: Duration(seconds: 30),
|
||||||
|
// A slider drag emits many position updates; only one checkpoint per
|
||||||
|
// window reaches MDBList.
|
||||||
|
seekThrottle: Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
void rebindSession(
|
||||||
|
TrackerSession? session, {
|
||||||
|
required void Function() onSessionInvalidated,
|
||||||
|
void Function(TrackerSession session)? onSessionUpdated,
|
||||||
|
http.Client? httpClient,
|
||||||
|
}) {
|
||||||
|
rebindTrackerClient(
|
||||||
|
session,
|
||||||
|
createClient: (session) => MdblistClient(
|
||||||
|
session,
|
||||||
|
onSessionInvalidated: onSessionInvalidated,
|
||||||
|
onSessionUpdated: onSessionUpdated,
|
||||||
|
httpClient: httpClient,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MDBList matches on the media server's own external ids and nothing else.
|
||||||
|
@override
|
||||||
|
String? historyRowIdentity(TrackerContext ctx) => trackerExternalRowIdentity(ctx.external);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markWatched(TrackerContext ctx, {DateTime? watchedAt}) async {
|
||||||
|
final client = this.client;
|
||||||
|
if (client == null || !canWriteWatched) return;
|
||||||
|
final body = _watchedBody(ctx, watchedAt: watchedAt);
|
||||||
|
if (body == null) return;
|
||||||
|
|
||||||
|
await client.addToWatched(body);
|
||||||
|
appLogger.d('MDBList: marked watched (${ctx.ratingKey}, isMovie=${ctx.isMovie})');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markUnwatched(TrackerContext ctx) async {
|
||||||
|
final client = this.client;
|
||||||
|
if (client == null || !canWriteWatched) return;
|
||||||
|
final body = _watchedBody(ctx);
|
||||||
|
if (body == null) return;
|
||||||
|
|
||||||
|
await client.removeFromWatched(body);
|
||||||
|
appLogger.d('MDBList: marked unwatched (${ctx.ratingKey}, isMovie=${ctx.isMovie})');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> scrobble(TrackerContext ctx, TrackerScrobbleState state, double progressPercent) async {
|
||||||
|
final client = this.client;
|
||||||
|
if (client == null) return;
|
||||||
|
final body = _scrobbleBody(ctx, progressPercent);
|
||||||
|
if (body == null) return;
|
||||||
|
|
||||||
|
final action = switch (state) {
|
||||||
|
TrackerScrobbleState.start => 'start',
|
||||||
|
TrackerScrobbleState.pause => 'pause',
|
||||||
|
// MDBList has no seek event, but `start` is documented as upserting the
|
||||||
|
// session's progress, so one re-start checkpoints the new position
|
||||||
|
// without the pause+start pair Trakt needs.
|
||||||
|
TrackerScrobbleState.seek => 'start',
|
||||||
|
TrackerScrobbleState.stop => 'stop',
|
||||||
|
};
|
||||||
|
await client.scrobble(action, body);
|
||||||
|
appLogger.d('MDBList: scrobble ${state.name} @ ${progressPercent.toStringAsFixed(1)}%');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reconcileWatchedAfterStop(TrackerContext ctx, double progressPercent) async {
|
||||||
|
// At or above MDBList's own rule the stop already recorded the watch; a
|
||||||
|
// `/sync/watched` write would record a second one.
|
||||||
|
if (progressPercent >= _scrobbleWatchedPercent) return;
|
||||||
|
appLogger.d('MDBList: stop below ${_scrobbleWatchedPercent.toStringAsFixed(0)}% — recording watch explicitly');
|
||||||
|
await markWatched(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/sync/watched` and its `/remove` sibling share one shape; the remove
|
||||||
|
/// variant simply carries no timestamps.
|
||||||
|
Map<String, dynamic>? _watchedBody(TrackerContext ctx, {DateTime? watchedAt}) {
|
||||||
|
final ids = _ids(ctx.external);
|
||||||
|
if (ids.isEmpty) return null;
|
||||||
|
final stamp = watchedAt?.toUtc().toIso8601String();
|
||||||
|
|
||||||
|
if (ctx.isMovie) {
|
||||||
|
return {
|
||||||
|
'movies': [
|
||||||
|
{'ids': ids, 'watched_at': ?stamp},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final season = ctx.season;
|
||||||
|
final number = ctx.episodeNumber;
|
||||||
|
if (season == null || number == null) return null;
|
||||||
|
return {
|
||||||
|
'shows': [
|
||||||
|
{
|
||||||
|
'ids': ids,
|
||||||
|
'seasons': [
|
||||||
|
{
|
||||||
|
'number': season,
|
||||||
|
'episodes': [
|
||||||
|
{'number': number, 'watched_at': ?stamp},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrobble nests the episode inside the show as `show.season.episode`,
|
||||||
|
/// unlike the sibling `episode` object Trakt and Simkl accept.
|
||||||
|
Map<String, dynamic>? _scrobbleBody(TrackerContext ctx, double progressPercent) {
|
||||||
|
final ids = _ids(ctx.external);
|
||||||
|
if (ids.isEmpty) return null;
|
||||||
|
// MDBList rejects a progress outside 0-100; clamp rather than let a
|
||||||
|
// rounding overshoot fail the whole report.
|
||||||
|
final progress = double.parse(progressPercent.clamp(0, 100).toStringAsFixed(2));
|
||||||
|
|
||||||
|
if (ctx.isMovie) {
|
||||||
|
return {
|
||||||
|
'movie': {'ids': ids},
|
||||||
|
'progress': progress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final season = ctx.season;
|
||||||
|
final number = ctx.episodeNumber;
|
||||||
|
if (season == null || number == null) return null;
|
||||||
|
return {
|
||||||
|
'show': {
|
||||||
|
'ids': ids,
|
||||||
|
'season': {
|
||||||
|
'number': season,
|
||||||
|
'episode': {'number': number},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'progress': progress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the active client plus a non-empty id block, or refuse. Without
|
||||||
|
/// the id check a TVDB-only item would post `"ids": {}`, which MDBList would
|
||||||
|
/// accept as a write against nothing.
|
||||||
|
(MdblistClient, Map<String, Object>) _ratingTarget(TrackerRatingContext ctx) {
|
||||||
|
final activeClient = client;
|
||||||
|
if (activeClient == null) throw const TrackerRatingUnavailableException('MDBList');
|
||||||
|
final ids = _ids(ctx.ids.external);
|
||||||
|
if (ids.isEmpty) throw const TrackerRatingUnavailableException('MDBList');
|
||||||
|
return (activeClient, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int?> getRating(TrackerRatingContext ctx) async {
|
||||||
|
final (client, localIds) = _ratingTarget(ctx);
|
||||||
|
|
||||||
|
final entries = await client.getRatings(_ratingType(ctx));
|
||||||
|
for (final entry in entries) {
|
||||||
|
if (entry is! Map) continue;
|
||||||
|
final map = entry.cast<String, dynamic>();
|
||||||
|
if (!_ratingEntryMatches(ctx, map, localIds)) continue;
|
||||||
|
final rating = flexibleInt(map['rating']);
|
||||||
|
return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> rate(TrackerRatingContext ctx, int score) async {
|
||||||
|
final (client, ids) = _ratingTarget(ctx);
|
||||||
|
await client.addRatings(_ratingBody(ctx, ids, rating: score.clamp(1, 10).toInt()));
|
||||||
|
appLogger.d('MDBList: updated score (${ctx.kind.name}, score=$score)');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearRating(TrackerRatingContext ctx) async {
|
||||||
|
final (client, ids) = _ratingTarget(ctx);
|
||||||
|
await client.removeRatings(_ratingBody(ctx, ids));
|
||||||
|
appLogger.d('MDBList: cleared score (${ctx.kind.name})');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _ratingType(TrackerRatingContext ctx) => switch (ctx.kind) {
|
||||||
|
MediaKind.movie => 'movies',
|
||||||
|
MediaKind.show => 'shows',
|
||||||
|
MediaKind.season => 'seasons',
|
||||||
|
MediaKind.episode => 'episodes',
|
||||||
|
_ => throw const TrackerRatingUnavailableException('MDBList'),
|
||||||
|
};
|
||||||
|
|
||||||
|
bool _ratingEntryMatches(TrackerRatingContext ctx, Map<String, dynamic> entry, Map<String, Object> localIds) {
|
||||||
|
final show = entry['show'];
|
||||||
|
final movie = entry['movie'];
|
||||||
|
return switch (ctx.kind) {
|
||||||
|
MediaKind.movie => trackerIdsMatch(trackerNestedIds(movie), localIds),
|
||||||
|
MediaKind.show => trackerIdsMatch(trackerNestedIds(show), localIds),
|
||||||
|
MediaKind.season =>
|
||||||
|
trackerIdsMatch(trackerNestedIds(_nestedShow(entry['season']) ?? show), localIds) &&
|
||||||
|
_numberMatches(entry['season'], ctx.season),
|
||||||
|
MediaKind.episode =>
|
||||||
|
trackerIdsMatch(trackerNestedIds(_nestedShow(entry['episode']) ?? show), localIds) &&
|
||||||
|
_numberMatches(entry['episode'], ctx.episodeNumber) &&
|
||||||
|
_seasonMatches(entry['episode'], ctx.season),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Season and episode rating rows carry their parent show inline rather than
|
||||||
|
/// as a sibling key, so prefer that when present.
|
||||||
|
Object? _nestedShow(Object? value) => value is Map ? value['show'] : null;
|
||||||
|
|
||||||
|
bool _numberMatches(Object? value, int? expected) {
|
||||||
|
if (expected == null || value is! Map) return false;
|
||||||
|
return flexibleInt(value['number']) == expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _seasonMatches(Object? value, int? expected) {
|
||||||
|
if (expected == null || value is! Map) return false;
|
||||||
|
return flexibleInt(value['season']) == expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _ratingBody(TrackerRatingContext ctx, Map<String, Object> ids, {int? rating}) {
|
||||||
|
final item = {'ids': ids, 'rating': ?rating};
|
||||||
|
|
||||||
|
return switch (ctx.kind) {
|
||||||
|
MediaKind.movie => {
|
||||||
|
'movies': [item],
|
||||||
|
},
|
||||||
|
MediaKind.show => {
|
||||||
|
'shows': [item],
|
||||||
|
},
|
||||||
|
MediaKind.season => {
|
||||||
|
'shows': [
|
||||||
|
{
|
||||||
|
'ids': ids,
|
||||||
|
'seasons': [
|
||||||
|
{'number': ctx.season, 'rating': ?rating},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MediaKind.episode => {
|
||||||
|
'shows': [
|
||||||
|
{
|
||||||
|
'ids': ids,
|
||||||
|
'seasons': [
|
||||||
|
{
|
||||||
|
'number': ctx.season,
|
||||||
|
'episodes': [
|
||||||
|
{'number': ctx.episodeNumber, 'rating': ?rating},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
_ => throw const TrackerRatingUnavailableException('MDBList'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MDBList's id block. TVDB is deliberately absent — the API does not accept
|
||||||
|
/// it, so a TVDB-only item yields an empty map and every write no-ops.
|
||||||
|
Map<String, Object> _ids(ExternalIds external) => {'imdb': ?external.imdb, 'tmdb': ?external.tmdb};
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ class TrackerAccountStore {
|
|||||||
TrackerService.anilist: TrackerAccountStore._(TrackerService.anilist, 'anilist_session'),
|
TrackerService.anilist: TrackerAccountStore._(TrackerService.anilist, 'anilist_session'),
|
||||||
TrackerService.simkl: TrackerAccountStore._(TrackerService.simkl, 'simkl_session'),
|
TrackerService.simkl: TrackerAccountStore._(TrackerService.simkl, 'simkl_session'),
|
||||||
TrackerService.trakt: TrackerAccountStore._(TrackerService.trakt, 'trakt_session'),
|
TrackerService.trakt: TrackerAccountStore._(TrackerService.trakt, 'trakt_session'),
|
||||||
|
TrackerService.mdblist: TrackerAccountStore._(TrackerService.mdblist, 'mdblist_session'),
|
||||||
};
|
};
|
||||||
|
|
||||||
static TrackerAccountStore forService(TrackerService service) => _stores[service]!;
|
static TrackerAccountStore forService(TrackerService service) => _stores[service]!;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TrackerConstants {
|
|||||||
/// Identifier used across the app to disambiguate per-service operations.
|
/// Identifier used across the app to disambiguate per-service operations.
|
||||||
/// The enum's `.name` forms part of the persistence key — do not rename
|
/// The enum's `.name` forms part of the persistence key — do not rename
|
||||||
/// without a migration.
|
/// 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.
|
/// Blacklist+[] syncs every library (the default); whitelist+[] syncs nothing.
|
||||||
enum TrackerLibraryFilterMode { blacklist, whitelist }
|
enum TrackerLibraryFilterMode { blacklist, whitelist }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'anime_lists_mapping_store.dart';
|
|||||||
import 'anilist/anilist_tracker.dart';
|
import 'anilist/anilist_tracker.dart';
|
||||||
import 'fribb_mapping_store.dart';
|
import 'fribb_mapping_store.dart';
|
||||||
import 'mal/mal_tracker.dart';
|
import 'mal/mal_tracker.dart';
|
||||||
|
import 'mdblist/mdblist_tracker.dart';
|
||||||
import 'simkl/simkl_tracker.dart';
|
import 'simkl/simkl_tracker.dart';
|
||||||
import 'tracker.dart';
|
import 'tracker.dart';
|
||||||
import 'tracker_constants.dart';
|
import 'tracker_constants.dart';
|
||||||
@@ -24,10 +25,10 @@ import 'trakt/trakt_tracker.dart';
|
|||||||
///
|
///
|
||||||
/// Three mechanisms, chosen per tracker kind:
|
/// Three mechanisms, chosen per tracker kind:
|
||||||
///
|
///
|
||||||
/// * [RealtimeScrobbleTracker]s (Simkl, Trakt) receive the playback lifecycle —
|
/// * [RealtimeScrobbleTracker]s (Simkl, Trakt, MDBList) receive the playback
|
||||||
/// start/resume, pause, seek, stop — with the current progress, and decide
|
/// lifecycle — start/resume, pause, seek, stop — with the current progress,
|
||||||
/// watched state themselves. They are excluded from the threshold fan-out so
|
/// and decide watched state themselves. They are excluded from the threshold
|
||||||
/// a single watch never produces two writes.
|
/// fan-out so a single watch never produces two writes.
|
||||||
/// * Threshold trackers (MAL, AniList) are notified exactly once when progress
|
/// * Threshold trackers (MAL, AniList) are notified exactly once when progress
|
||||||
/// crosses the watched threshold, with a safety-net fire on stop if the
|
/// crosses the watched threshold, with a safety-net fire on stop if the
|
||||||
/// crossing was missed (e.g. the user stopped between ticks).
|
/// crossing was missed (e.g. the user stopped between ticks).
|
||||||
@@ -51,6 +52,7 @@ class TrackerCoordinator {
|
|||||||
AnilistTracker.instance,
|
AnilistTracker.instance,
|
||||||
SimklTracker.instance,
|
SimklTracker.instance,
|
||||||
TraktTracker.instance,
|
TraktTracker.instance,
|
||||||
|
MdblistTracker.instance,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// One transport per real-time tracker, created once and outliving individual
|
/// One transport per real-time tracker, created once and outliving individual
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class TrackerSession {
|
|||||||
switch (service) {
|
switch (service) {
|
||||||
case TrackerService.mal:
|
case TrackerService.mal:
|
||||||
case TrackerService.trakt:
|
case TrackerService.trakt:
|
||||||
|
case TrackerService.mdblist:
|
||||||
_validateRefreshToken(service, refreshToken);
|
_validateRefreshToken(service, refreshToken);
|
||||||
requireExpiry();
|
requireExpiry();
|
||||||
case TrackerService.anilist:
|
case TrackerService.anilist:
|
||||||
@@ -119,6 +120,15 @@ class TrackerSession {
|
|||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
),
|
),
|
||||||
TrackerService.simkl => TrackerSession(accessToken: json['access_token'] as String, 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(
|
TrackerService.trakt => TrackerSession(
|
||||||
accessToken: json['access_token'] as String,
|
accessToken: json['access_token'] as String,
|
||||||
refreshToken: _requireRefreshToken(service, json['refresh_token'] as String?),
|
refreshToken: _requireRefreshToken(service, json['refresh_token'] as String?),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class CatalogSourceLogo extends StatelessWidget {
|
|||||||
CatalogSourceId.anilist => 'assets/anilist_mark.svg',
|
CatalogSourceId.anilist => 'assets/anilist_mark.svg',
|
||||||
CatalogSourceId.simkl => 'assets/simkl_mark.svg',
|
CatalogSourceId.simkl => 'assets/simkl_mark.svg',
|
||||||
CatalogSourceId.seerr => 'assets/seerr_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;
|
final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface;
|
||||||
return SvgPicture.asset(
|
return SvgPicture.asset(
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ flutter:
|
|||||||
- assets/anilist_mark.svg
|
- assets/anilist_mark.svg
|
||||||
- assets/simkl_mark.svg
|
- assets/simkl_mark.svg
|
||||||
- assets/seerr_mark.svg
|
- assets/seerr_mark.svg
|
||||||
|
- assets/mdblist_mark.svg
|
||||||
- assets/go-noto-current-regular.ttf
|
- assets/go-noto-current-regular.ttf
|
||||||
- assets/shaders/nvscaler/
|
- assets/shaders/nvscaler/
|
||||||
- assets/shaders/artcnn/
|
- assets/shaders/artcnn/
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ void main() {
|
|||||||
final companionProviders = <CompanionRemoteProvider>[];
|
final companionProviders = <CompanionRemoteProvider>[];
|
||||||
final disposedActiveIds = <String>[];
|
final disposedActiveIds = <String>[];
|
||||||
final trackerHttpClients = <FakeHttpClient>[];
|
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).
|
// services (MAL's proxy and token exchange use separate clients).
|
||||||
const trackerAuthClientsPerProfile = 5;
|
const trackerAuthClientsPerProfile = 6;
|
||||||
FakeHttpClient trackerHttpClientFactory() {
|
FakeHttpClient trackerHttpClientFactory() {
|
||||||
final client = FakeHttpClient(200, const <int>[]);
|
final client = FakeHttpClient(200, const <int>[]);
|
||||||
trackerHttpClients.add(client);
|
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_coordinator.dart';
|
||||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||||
import 'package:plezy/services/trackers/mal/mal_tracker.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/simkl/simkl_tracker.dart';
|
||||||
import 'package:plezy/services/trackers/trakt/trakt_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 _anilistStore = trackerAccountStore(TrackerService.anilist);
|
||||||
final _simklStore = trackerAccountStore(TrackerService.simkl);
|
final _simklStore = trackerAccountStore(TrackerService.simkl);
|
||||||
final _traktStore = trackerAccountStore(TrackerService.trakt);
|
final _traktStore = trackerAccountStore(TrackerService.trakt);
|
||||||
|
final _mdblistStore = trackerAccountStore(TrackerService.mdblist);
|
||||||
|
|
||||||
TrackerSession _mal({String? username}) => TrackerSession(
|
TrackerSession _mal({String? username}) => TrackerSession(
|
||||||
accessToken: 'mal-at',
|
accessToken: 'mal-at',
|
||||||
@@ -68,18 +70,22 @@ void main() {
|
|||||||
expect(p.anilist, isNull);
|
expect(p.anilist, isNull);
|
||||||
expect(p.simkl, isNull);
|
expect(p.simkl, isNull);
|
||||||
expect(p.trakt, isNull);
|
expect(p.trakt, isNull);
|
||||||
|
expect(p.mdblist, isNull);
|
||||||
expect(p.isMalConnected, isFalse);
|
expect(p.isMalConnected, isFalse);
|
||||||
expect(p.isAnilistConnected, isFalse);
|
expect(p.isAnilistConnected, isFalse);
|
||||||
expect(p.isSimklConnected, isFalse);
|
expect(p.isSimklConnected, isFalse);
|
||||||
expect(p.isTraktConnected, isFalse);
|
expect(p.isTraktConnected, isFalse);
|
||||||
|
expect(p.isMdblistConnected, isFalse);
|
||||||
expect(p.malUsername, isNull);
|
expect(p.malUsername, isNull);
|
||||||
expect(p.anilistUsername, isNull);
|
expect(p.anilistUsername, isNull);
|
||||||
expect(p.simklUsername, isNull);
|
expect(p.simklUsername, isNull);
|
||||||
expect(p.traktUsername, isNull);
|
expect(p.traktUsername, isNull);
|
||||||
|
expect(p.mdblistUsername, isNull);
|
||||||
expect(p.isConnecting(TrackerService.mal), isFalse);
|
expect(p.isConnecting(TrackerService.mal), isFalse);
|
||||||
expect(p.isConnecting(TrackerService.anilist), isFalse);
|
expect(p.isConnecting(TrackerService.anilist), isFalse);
|
||||||
expect(p.isConnecting(TrackerService.simkl), isFalse);
|
expect(p.isConnecting(TrackerService.simkl), isFalse);
|
||||||
expect(p.isConnecting(TrackerService.trakt), isFalse);
|
expect(p.isConnecting(TrackerService.trakt), isFalse);
|
||||||
|
expect(p.isConnecting(TrackerService.mdblist), isFalse);
|
||||||
p.dispose();
|
p.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,8 +100,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(clients, hasLength(5));
|
expect(clients, hasLength(6));
|
||||||
expect(clients.toSet(), hasLength(5));
|
expect(clients.toSet(), hasLength(6));
|
||||||
for (final client in clients) {
|
for (final client in clients) {
|
||||||
expect(client.closeCount, 0);
|
expect(client.closeCount, 0);
|
||||||
}
|
}
|
||||||
@@ -264,7 +270,13 @@ void main() {
|
|||||||
// Post-dispose rebind should not throw.
|
// Post-dispose rebind should not throw.
|
||||||
await _bindProfile(p, 'any-uuid');
|
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 {
|
test('$service stale connect cannot save or replace a newer binding after dispose', () async {
|
||||||
const oldUuid = 'profile-old';
|
const oldUuid = 'profile-old';
|
||||||
const newUuid = 'profile-new';
|
const newUuid = 'profile-new';
|
||||||
@@ -422,6 +434,7 @@ void _resetTrackerBindings() {
|
|||||||
AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||||
SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||||
TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||||
|
MdblistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackerAccountStore _store(TrackerService service) => switch (service) {
|
TrackerAccountStore _store(TrackerService service) => switch (service) {
|
||||||
@@ -429,6 +442,7 @@ TrackerAccountStore _store(TrackerService service) => switch (service) {
|
|||||||
TrackerService.anilist => _anilistStore,
|
TrackerService.anilist => _anilistStore,
|
||||||
TrackerService.simkl => _simklStore,
|
TrackerService.simkl => _simklStore,
|
||||||
TrackerService.trakt => _traktStore,
|
TrackerService.trakt => _traktStore,
|
||||||
|
TrackerService.mdblist => _mdblistStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
TrackerSession _session(TrackerService service, String owner) => switch (service) {
|
TrackerSession _session(TrackerService service, String owner) => switch (service) {
|
||||||
@@ -453,6 +467,13 @@ TrackerSession _session(TrackerService service, String owner) => switch (service
|
|||||||
createdAt: 1900000000,
|
createdAt: 1900000000,
|
||||||
username: owner,
|
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) {
|
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.anilist => provider.connectAnilist(onCodeReady: (_) {}),
|
||||||
TrackerService.simkl => provider.connectSimkl(onCodeReady: (_) {}),
|
TrackerService.simkl => provider.connectSimkl(onCodeReady: (_) {}),
|
||||||
TrackerService.trakt => provider.connectTrakt(onCodeReady: (_) {}),
|
TrackerService.trakt => provider.connectTrakt(onCodeReady: (_) {}),
|
||||||
|
TrackerService.mdblist => provider.connectMdblist(onCodeReady: (_) {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
TrackerSession? _providerSession(TrackersProvider provider, TrackerService service) => switch (service) {
|
TrackerSession? _providerSession(TrackersProvider provider, TrackerService service) => switch (service) {
|
||||||
@@ -467,6 +489,7 @@ TrackerSession? _providerSession(TrackersProvider provider, TrackerService servi
|
|||||||
TrackerService.anilist => provider.anilist,
|
TrackerService.anilist => provider.anilist,
|
||||||
TrackerService.simkl => provider.simkl,
|
TrackerService.simkl => provider.simkl,
|
||||||
TrackerService.trakt => provider.trakt,
|
TrackerService.trakt => provider.trakt,
|
||||||
|
TrackerService.mdblist => provider.mdblist,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object? _boundClient(TrackerService service) => switch (service) {
|
Object? _boundClient(TrackerService service) => switch (service) {
|
||||||
@@ -474,6 +497,7 @@ Object? _boundClient(TrackerService service) => switch (service) {
|
|||||||
TrackerService.anilist => AnilistTracker.instance.client,
|
TrackerService.anilist => AnilistTracker.instance.client,
|
||||||
TrackerService.simkl => SimklTracker.instance.client,
|
TrackerService.simkl => SimklTracker.instance.client,
|
||||||
TrackerService.trakt => TraktTracker.instance.client,
|
TrackerService.trakt => TraktTracker.instance.client,
|
||||||
|
TrackerService.mdblist => MdblistTracker.instance.client,
|
||||||
};
|
};
|
||||||
|
|
||||||
TrackerSession? _boundSession(TrackerService service) => switch (service) {
|
TrackerSession? _boundSession(TrackerService service) => switch (service) {
|
||||||
@@ -481,6 +505,7 @@ TrackerSession? _boundSession(TrackerService service) => switch (service) {
|
|||||||
TrackerService.anilist => AnilistTracker.instance.client?.session,
|
TrackerService.anilist => AnilistTracker.instance.client?.session,
|
||||||
TrackerService.simkl => SimklTracker.instance.client?.session,
|
TrackerService.simkl => SimklTracker.instance.client?.session,
|
||||||
TrackerService.trakt => TraktTracker.instance.client?.session,
|
TrackerService.trakt => TraktTracker.instance.client?.session,
|
||||||
|
TrackerService.mdblist => MdblistTracker.instance.client?.session,
|
||||||
};
|
};
|
||||||
|
|
||||||
class _ControlledConnectPipeline {
|
class _ControlledConnectPipeline {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(clients, hasLength(5));
|
expect(clients, hasLength(6));
|
||||||
for (final client in clients) {
|
for (final client in clients) {
|
||||||
expect(client.closeCount, 0);
|
expect(client.closeCount, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,8 +612,8 @@ class _SettingsHarness {
|
|||||||
activeProfile.dispose();
|
activeProfile.dispose();
|
||||||
await plexHome.dispose();
|
await plexHome.dispose();
|
||||||
await database.close();
|
await database.close();
|
||||||
expect(trackerHttpClients, hasLength(5));
|
expect(trackerHttpClients, hasLength(6));
|
||||||
expect(trackerHttpClients.toSet(), hasLength(5));
|
expect(trackerHttpClients.toSet(), hasLength(6));
|
||||||
for (final client in trackerHttpClients) {
|
for (final client in trackerHttpClients) {
|
||||||
expect(client.closeCount, 1);
|
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