diff --git a/assets/mdblist_mark.svg b/assets/mdblist_mark.svg
new file mode 100644
index 00000000..f0dfcfe5
--- /dev/null
+++ b/assets/mdblist_mark.svg
@@ -0,0 +1 @@
+
diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json
index 52925cfe..79dce66b 100644
--- a/lib/i18n/az.i18n.json
+++ b/lib/i18n/az.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Plezy-ni ${service} üzərində aktivləşdirin",
diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json
index b891116d..13d0c7c1 100644
--- a/lib/i18n/bg.i18n.json
+++ b/lib/i18n/bg.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Активиране на Plezy в ${service}",
diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json
index 7b13da37..013a80fd 100644
--- a/lib/i18n/da.i18n.json
+++ b/lib/i18n/da.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Aktiver Plezy på ${service}",
diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json
index fd02e1bf..e40b00e6 100644
--- a/lib/i18n/de.i18n.json
+++ b/lib/i18n/de.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Plezy auf ${service} aktivieren",
diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json
index d66b40e5..c5bcfdb1 100644
--- a/lib/i18n/en.i18n.json
+++ b/lib/i18n/en.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": "MDBList"
},
"deviceCode": {
"title": "Activate Plezy on ${service}",
diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json
index e581bf61..e8f8d11a 100644
--- a/lib/i18n/es.i18n.json
+++ b/lib/i18n/es.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Activar Plezy en ${service}",
diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json
index 51b5ee53..9f3b0c48 100644
--- a/lib/i18n/fr.i18n.json
+++ b/lib/i18n/fr.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Activer Plezy sur ${service}",
diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json
index 099a8569..132b1ca9 100644
--- a/lib/i18n/hu.i18n.json
+++ b/lib/i18n/hu.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Plezy aktiválása a következőn: ${service}",
diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json
index c3a07485..e2e21996 100644
--- a/lib/i18n/it.i18n.json
+++ b/lib/i18n/it.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Attiva Plezy su ${service}",
diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json
index b8fc6caf..8e021c89 100644
--- a/lib/i18n/ja.i18n.json
+++ b/lib/i18n/ja.i18n.json
@@ -1834,7 +1834,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "${service} で Plezy を有効化",
diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json
index 8f634355..c30420d6 100644
--- a/lib/i18n/kk.i18n.json
+++ b/lib/i18n/kk.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Plezy-ді ${service} қызметінде белсендіру",
diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json
index 18ba9b36..ee2d7870 100644
--- a/lib/i18n/ko.i18n.json
+++ b/lib/i18n/ko.i18n.json
@@ -1834,7 +1834,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "${service}에서 Plezy 활성화",
diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json
index b550b228..b5f1246f 100644
--- a/lib/i18n/nb.i18n.json
+++ b/lib/i18n/nb.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Aktiver Plezy på ${service}",
diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json
index 38618aad..d7df7c1c 100644
--- a/lib/i18n/nl.i18n.json
+++ b/lib/i18n/nl.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Plezy activeren op ${service}",
diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json
index 40896f89..5fdb94c2 100644
--- a/lib/i18n/pl.i18n.json
+++ b/lib/i18n/pl.i18n.json
@@ -1852,7 +1852,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Aktywuj Plezy w ${service}",
diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json
index ce5b4b7b..0cdec741 100644
--- a/lib/i18n/pt.i18n.json
+++ b/lib/i18n/pt.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Ativar o Plezy no ${service}",
diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json
index 4d8d399d..7529b9d4 100644
--- a/lib/i18n/ru.i18n.json
+++ b/lib/i18n/ru.i18n.json
@@ -1852,7 +1852,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Активируйте Plezy в ${service}",
diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart
index 906445ed..597d9896 100644
--- a/lib/i18n/strings.g.dart
+++ b/lib/i18n/strings.g.dart
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
-/// Strings: 32736 (1488 per locale)
+/// Strings: 32737 (1488 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart
index 732842fe..73308506 100644
--- a/lib/i18n/strings_en.g.dart
+++ b/lib/i18n/strings_en.g.dart
@@ -5928,6 +5928,9 @@ class Translations$services$names$en {
/// en: 'Seerr'
String get seerr => 'Seerr';
+
+ /// en: 'MDBList'
+ String get mdblist => 'MDBList';
}
// Path: services.deviceCode
@@ -7721,6 +7724,7 @@ extension on Translations {
'services.names.anilist' => 'AniList',
'services.names.simkl' => 'Simkl',
'services.names.seerr' => 'Seerr',
+ 'services.names.mdblist' => 'MDBList',
'services.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}',
'services.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:',
'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate',
diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json
index e22ab0dc..bef4f5eb 100644
--- a/lib/i18n/sv.i18n.json
+++ b/lib/i18n/sv.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Aktivera Plezy på ${service}",
diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json
index 5ecdc84e..9ddba236 100644
--- a/lib/i18n/tr.i18n.json
+++ b/lib/i18n/tr.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Plezy'yi ${service} üzerinde etkinleştirin",
diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json
index 12e27649..5c021374 100644
--- a/lib/i18n/uz.i18n.json
+++ b/lib/i18n/uz.i18n.json
@@ -1840,7 +1840,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "Plezy-ni ${service} xizmatida faollashtiring",
diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json
index 37a36730..d3be3e2c 100644
--- a/lib/i18n/zh-Hant.i18n.json
+++ b/lib/i18n/zh-Hant.i18n.json
@@ -1834,7 +1834,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "在 ${service} 啟用 Plezy",
diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json
index 6d791645..bffe47cf 100644
--- a/lib/i18n/zh.i18n.json
+++ b/lib/i18n/zh.i18n.json
@@ -1834,7 +1834,8 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
- "seerr": "Seerr"
+ "seerr": "Seerr",
+ "mdblist": ""
},
"deviceCode": {
"title": "在 ${service} 上激活 Plezy",
diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart
index 8ee5ee57..2fbebd49 100644
--- a/lib/models/catalog/catalog_item.dart
+++ b/lib/models/catalog/catalog_item.dart
@@ -7,7 +7,7 @@ import 'catalog_cast_member.dart';
import 'catalog_metadata.dart';
/// External catalog providers that can back the Explore tab.
-enum CatalogSourceId { plex, trakt, mal, anilist, simkl, seerr }
+enum CatalogSourceId { plex, trakt, mal, anilist, simkl, seerr, mdblist }
/// Normalized airing/production status across providers (Trakt `status`,
/// MAL `status`). Null when unknown or uninteresting (released movies).
diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart
index cefaef4f..b7cb30e9 100644
--- a/lib/providers/trackers_provider.dart
+++ b/lib/providers/trackers_provider.dart
@@ -10,6 +10,9 @@ import '../services/trackers/anilist/anilist_tracker.dart';
import '../services/trackers/mal/mal_auth_service.dart';
import '../services/trackers/mal/mal_client.dart';
import '../services/trackers/mal/mal_tracker.dart';
+import '../services/trackers/mdblist/mdblist_auth_service.dart';
+import '../services/trackers/mdblist/mdblist_client.dart';
+import '../services/trackers/mdblist/mdblist_tracker.dart';
import '../services/trackers/oauth_proxy_client.dart';
import '../services/trackers/simkl/simkl_auth_service.dart';
import '../services/trackers/simkl/simkl_client.dart';
@@ -35,10 +38,10 @@ typedef TrackerSessionConnectPipeline =
required void Function(TrackerSession enriched) assign,
});
-/// Owns the active MAL / AniList / Simkl / Trakt sessions for the
+/// Owns the active MAL / AniList / Simkl / Trakt / MDBList sessions for the
/// currently-selected Plex profile. Single rebind seam:
-/// [onActiveProfileChanged] loads all four sessions from their stores and
-/// pushes them to their trackers.
+/// [onActiveProfileChanged] loads every session from its store and pushes it
+/// to the matching tracker.
class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
/// [httpClientFactory] must return a fresh client for each eager auth owner.
/// Every returned client is closed when this provider is disposed.
@@ -62,13 +65,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
? AnilistAuthService()
: AnilistAuthService(proxy: OAuthProxyClient(httpClient: httpClientFactory())),
_simklAuth = httpClientFactory == null ? SimklAuthService() : SimklAuthService(httpClient: httpClientFactory()),
- _traktAuth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory());
+ _traktAuth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory()),
+ _mdblistAuth = httpClientFactory == null
+ ? MdblistAuthService()
+ : MdblistAuthService(httpClient: httpClientFactory());
final TrackerSessionConnectPipeline _connectPipeline;
final MalAuthService _malAuth;
final AnilistAuthService _anilistAuth;
final SimklAuthService _simklAuth;
final TraktAuthService _traktAuth;
+ final MdblistAuthService _mdblistAuth;
final _TrackerSlot _mal = _TrackerSlot(
TrackerService.mal,
@@ -90,7 +97,15 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
(session, {required onInvalidated, onUpdated}) =>
TraktTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: onUpdated),
);
- late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl, _trakt];
+ final _TrackerSlot _mdblist = _TrackerSlot(
+ TrackerService.mdblist,
+ (session, {required onInvalidated, onUpdated}) => MdblistTracker.instance.rebindSession(
+ session,
+ onSessionInvalidated: onInvalidated,
+ onSessionUpdated: onUpdated,
+ ),
+ );
+ late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl, _trakt, _mdblist];
String _activeUserUuid = '';
int _profileBindingGeneration = 0;
@@ -102,11 +117,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
TrackerSession? get anilist => _anilist.session;
TrackerSession? get simkl => _simkl.session;
TrackerSession? get trakt => _trakt.session;
+ TrackerSession? get mdblist => _mdblist.session;
bool get isMalConnected => _mal.session != null;
bool get isAnilistConnected => _anilist.session != null;
bool get isSimklConnected => _simkl.session != null;
bool get isTraktConnected => _trakt.session != null;
+ bool get isMdblistConnected => _mdblist.session != null;
/// The live MAL client for the Explore catalog, shared with the scrobble
/// tracker so both ride one session (MAL rotates refresh tokens — a second
@@ -132,6 +149,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
String? get anilistUsername => _anilist.session?.username;
String? get simklUsername => _simkl.session?.username;
String? get traktUsername => _trakt.session?.username;
+ String? get mdblistUsername => _mdblist.session?.username;
bool isConnecting(TrackerService service) => _connecting == service;
@@ -245,6 +263,34 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
+ Future connectMdblist({required void Function(DeviceCode code) onCodeReady}) => _runConnect(
+ _mdblist,
+ authorize: () => _mdblistAuth.authorize(
+ onCodeReady: onCodeReady,
+ shouldCancel: _isConnectCancelled,
+ onCancel: _cancelCompleter!.future,
+ ),
+ enrich: _enrichMdblist,
+ );
+
+ /// Like Trakt, MDBList can revoke its token server-side. Local state goes
+ /// first, so a failed revoke still leaves the user disconnected here — the
+ /// token just stays valid on MDBList's side until it expires.
+ Future disconnectMdblist() async {
+ final session = _mdblist.session;
+ await _clearAndRebind(_mdblist);
+ if (session == null) return;
+
+ // `revoke` logs and swallows its own failures; disposal is the only thing
+ // this caller still owns.
+ final client = MdblistClient(session, onSessionInvalidated: () {});
+ try {
+ await client.revoke();
+ } finally {
+ client.dispose();
+ }
+ }
+
bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false;
Future _runConnect(
@@ -352,6 +398,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
fetchUsername: (client) async => (await client.getUserSettings()).username,
);
+ Future _enrichMdblist(TrackerSession raw) => enrichTrackerSessionUsername(
+ session: raw,
+ failureMessage: 'MDBList: getUser failed (non-fatal)',
+ createClient: () => MdblistClient(raw, onSessionInvalidated: () {}),
+ fetchUsername: (client) async => (await client.getUser())?['username'] as String?,
+ );
+
/// Push a slot's session to its tracker, snapshotting the active profile and
/// bumping the slot's rebind generation first. Bumping here is what lets a
/// stale client callback — or a racing profile load — detect that it has been
@@ -386,6 +439,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_anilistAuth.dispose();
_simklAuth.dispose();
_traktAuth.dispose();
+ _mdblistAuth.dispose();
super.dispose();
}
}
diff --git a/lib/screens/settings/tracker_service_info.dart b/lib/screens/settings/tracker_service_info.dart
index 250a0e57..dde8d0f7 100644
--- a/lib/screens/settings/tracker_service_info.dart
+++ b/lib/screens/settings/tracker_service_info.dart
@@ -6,6 +6,7 @@ import '../../models/catalog/catalog_item.dart';
import '../../providers/trackers_provider.dart';
import '../../services/trackers/anilist/anilist_tracker.dart';
import '../../services/trackers/mal/mal_tracker.dart';
+import '../../services/trackers/mdblist/mdblist_tracker.dart';
import '../../services/trackers/simkl/simkl_tracker.dart';
import '../../services/trackers/tracker.dart';
import '../../services/trackers/tracker_constants.dart';
@@ -88,5 +89,11 @@ class TrackerServiceInfo {
ratingSource: SimklTracker.instance,
startConnection: startSimklConnection,
),
+ TrackerServiceInfo.shared(
+ TrackerConfig.mdblist(),
+ logoSource: CatalogSourceId.mdblist,
+ ratingSource: MdblistTracker.instance,
+ startConnection: startMdblistConnection,
+ ),
];
}
diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart
index 659f3b15..8c9b2e78 100644
--- a/lib/screens/settings/tracker_settings_screen.dart
+++ b/lib/screens/settings/tracker_settings_screen.dart
@@ -7,6 +7,7 @@ import '../../models/trackers/device_code.dart';
import '../../providers/trackers_provider.dart';
import '../../services/trackers/anilist/anilist_tracker.dart';
import '../../services/trackers/mal/mal_tracker.dart';
+import '../../services/trackers/mdblist/mdblist_tracker.dart';
import '../../services/trackers/oauth_proxy_client.dart';
import '../../services/trackers/simkl/simkl_tracker.dart';
import '../../services/trackers/tracker_constants.dart';
@@ -60,6 +61,20 @@ Future startSimklConnection(BuildContext context) {
);
}
+Future startMdblistConnection(BuildContext context) {
+ final account = context.read();
+ final name = t.services.names.mdblist;
+ return launchTrackerConnect(
+ context,
+ isBusyOrConnected: account.isConnecting(TrackerService.mdblist) || account.isMdblistConnected,
+ serviceName: name,
+ connect: (cb) => account.connectMdblist(onCodeReady: cb),
+ onCancel: account.cancelConnect,
+ buildDialog: (p, cancel) => DeviceCodeDialog(code: p, serviceName: name, onCancel: cancel),
+ urlFor: (p) => p.verificationUrlComplete ?? p.verificationUrl,
+ );
+}
+
/// Per-service wiring for [TrackerSettingsScreen]. Keeps tracker-specific
/// method names out of the shared screen body.
class TrackerConfig {
@@ -107,11 +122,20 @@ class TrackerConfig {
onScrobbleChanged: SimklTracker.instance.setEnabled,
disconnect: (a) => a.disconnectSimkl(),
);
+
+ static TrackerConfig mdblist() => TrackerConfig(
+ service: TrackerService.mdblist,
+ displayName: t.services.names.mdblist,
+ isConnected: (a) => a.isMdblistConnected,
+ username: (a) => a.mdblistUsername,
+ onScrobbleChanged: MdblistTracker.instance.setEnabled,
+ disconnect: (a) => a.disconnectMdblist(),
+ );
}
-/// Shared settings screen for MAL, AniList, and Simkl. Only reachable while
-/// connected — if the session drops (refresh failure, back-nav race) we pop
-/// back to the hub.
+/// Shared settings screen for MAL, AniList, Simkl and MDBList. Only reachable
+/// while connected — if the session drops (refresh failure, back-nav race) we
+/// pop back to the hub.
class TrackerSettingsScreen extends StatelessWidget {
final TrackerConfig config;
const TrackerSettingsScreen({super.key, required this.config});
diff --git a/lib/services/sensitive_prefs.dart b/lib/services/sensitive_prefs.dart
index b39d0713..7ba555f9 100644
--- a/lib/services/sensitive_prefs.dart
+++ b/lib/services/sensitive_prefs.dart
@@ -10,7 +10,7 @@
/// Losing it orphans every one of those ciphertexts permanently.
/// * tracker sessions — `TrackerAccountStore` persists `TrackerSession.encode()`
/// verbatim, so raw OAuth `access_token`/`refresh_token` pairs for MAL,
-/// AniList, Simkl and Trakt live here in plaintext.
+/// AniList, Simkl, Trakt and MDBList live here in plaintext.
/// * Seerr sessions — `SeerrSessionStore` persists a raw `connect.sid` cookie
/// alongside a vault-protected password.
/// * [legacyPlexTokenPref] — the pre-connection-registry Plex token slot. It is
@@ -44,6 +44,7 @@ const List trackerSessionBaseKeys = [
'anilist_session',
'simkl_session',
'trakt_session',
+ 'mdblist_session',
];
/// Unscoped base key used by `SeerrSessionStore`.
diff --git a/lib/services/trackers/mdblist/mdblist_auth_service.dart b/lib/services/trackers/mdblist/mdblist_auth_service.dart
new file mode 100644
index 00000000..f61662d7
--- /dev/null
+++ b/lib/services/trackers/mdblist/mdblist_auth_service.dart
@@ -0,0 +1,163 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+
+import '../../../models/trackers/device_code.dart';
+import '../../../utils/abortable_http_request.dart';
+import '../../../utils/app_logger.dart';
+import '../device_code_auth_service.dart';
+import '../tracker_constants.dart';
+import '../tracker_exceptions.dart';
+import '../tracker_session.dart';
+import 'mdblist_constants.dart';
+
+/// MDBList OAuth Device Authorization Grant (RFC 8628).
+///
+/// The user opens `mdblist.com/oauth/device/` on any second device and
+/// approves the shown code; the app polls the token endpoint meanwhile. No
+/// redirect URI, no local listener and no client secret are involved, so the
+/// same flow works identically on TV, mobile and desktop.
+///
+/// Unlike Trakt, MDBList reports poll state in the JSON body rather than
+/// through distinct status codes — `authorization_pending` and `slow_down`
+/// both arrive as HTTP 400 — so [probe] switches on `error`, not the status.
+class MdblistAuthService extends DeviceCodeAuthServiceBase {
+ /// MDBList returns these for a terminally-invalid grant (revoked or expired
+ /// refresh token); anything else (5xx, network) is transient and must not
+ /// log the user out.
+ static const Set _permanentRefreshFailureStatuses = {400, 401, 403};
+
+ /// Fallbacks for a response that omits them. MDBList currently answers with
+ /// a 30-minute window and a 5-second interval.
+ static const int _defaultExpiresIn = 1800;
+ static const int _defaultInterval = 5;
+
+ MdblistAuthService({super.httpClient});
+
+ @override
+ Future createDeviceCode() async {
+ final uri = Uri.parse(MdblistConstants.deviceAuthorizationUrl);
+ final res = await sendAbortableHttpRequest(
+ httpClient,
+ 'POST',
+ uri,
+ body: {'client_id': MdblistConstants.clientId, 'scope': MdblistConstants.scope},
+ timeout: TrackerConstants.authRequestTimeout,
+ operation: 'MDBList device code request',
+ );
+ appLogger.d('MDBList POST ${uri.path} → ${res.statusCode}');
+
+ if (res.statusCode != 200) {
+ throw DeviceCodeAuthFlowException('MDBList device code request failed: HTTP ${res.statusCode}');
+ }
+
+ final body = json.decode(res.body) as Map;
+ final userCode = body['user_code'] as String;
+ final verificationUrl = body['verification_uri'] as String? ?? MdblistConstants.verificationUrl;
+ return DeviceCode(
+ deviceCode: body['device_code'] as String,
+ userCode: userCode,
+ verificationUrl: verificationUrl,
+ // MDBList omits `verification_uri_complete`, but its device page seeds
+ // the form from `?user_code=`, so build the prefilled link ourselves and
+ // still prefer a server-supplied one if it ever starts sending it.
+ verificationUrlComplete:
+ body['verification_uri_complete'] as String? ??
+ MdblistConstants.verificationUrlFor(userCode, verificationUrl: verificationUrl),
+ expiresIn: (body['expires_in'] as num?)?.toInt() ?? _defaultExpiresIn,
+ interval: (body['interval'] as num?)?.toInt() ?? _defaultInterval,
+ );
+ }
+
+ @override
+ Future probe(DeviceCode code) async {
+ final tokenUri = Uri.parse(MdblistConstants.tokenUrl);
+ final http.Response res;
+ try {
+ res = await sendAbortableHttpRequest(
+ httpClient,
+ 'POST',
+ tokenUri,
+ body: {
+ 'grant_type': MdblistConstants.deviceCodeGrantType,
+ 'device_code': code.deviceCode,
+ 'client_id': MdblistConstants.clientId,
+ },
+ timeout: TrackerConstants.authRequestTimeout,
+ operation: 'MDBList device token poll',
+ );
+ } catch (e) {
+ appLogger.d('MDBList device-code poll error (transient)', error: e);
+ return const DevicePollPending();
+ }
+
+ final body = _decodeBody(res.body);
+ if (res.statusCode == 200 && body['access_token'] != null) {
+ return DevicePollSuccess(body);
+ }
+
+ return switch (body['error']) {
+ 'authorization_pending' => const DevicePollPending(),
+ 'slow_down' => const DevicePollSlowDown(),
+ 'access_denied' => const DevicePollDenied(),
+ // `device_not_found` is MDBList's own code for a device grant that no
+ // longer exists, which is terminal in the same way as an expiry.
+ 'expired_token' || 'device_not_found' => const DevicePollExpired(),
+ final error => _unexpected(error, res.statusCode),
+ };
+ }
+
+ /// Keep polling on anything unrecognised: the deadline in the poll loop
+ /// still bounds the flow, and treating an unknown code as terminal would
+ /// abandon an authorization the user may be about to complete.
+ static DevicePollEvent _unexpected(Object? error, int statusCode) {
+ appLogger.w('MDBList device-code unexpected response (HTTP $statusCode, error=$error)');
+ return const DevicePollPending();
+ }
+
+ static Map _decodeBody(String body) {
+ try {
+ final decoded = json.decode(body);
+ return decoded is Map ? decoded : const {};
+ } catch (_) {
+ return const {};
+ }
+ }
+
+ @override
+ TrackerSession buildSession(Map tokenResponse) =>
+ TrackerSession.fromTokenResponse(TrackerService.mdblist, tokenResponse);
+
+ /// Exchange the refresh token for a fresh access token. Public client, so
+ /// no secret rides along — only the client ID.
+ Future refresh(TrackerSession current) async {
+ final res = await sendAbortableHttpRequest(
+ httpClient,
+ 'POST',
+ Uri.parse(MdblistConstants.tokenUrl),
+ body: {
+ 'grant_type': 'refresh_token',
+ 'refresh_token': current.requireRefreshToken(TrackerService.mdblist),
+ 'client_id': MdblistConstants.clientId,
+ },
+ timeout: TrackerConstants.refreshTimeout,
+ operation: 'MDBList token refresh',
+ );
+
+ if (res.statusCode != 200) {
+ appLogger.w('MDBList: refresh failed (HTTP ${res.statusCode})');
+ throw TrackerAuthException(
+ service: TrackerService.mdblist,
+ message: 'Refresh failed: HTTP ${res.statusCode}',
+ statusCode: res.statusCode,
+ isPermanent: _permanentRefreshFailureStatuses.contains(res.statusCode),
+ );
+ }
+
+ final fresh = TrackerSession.fromTokenResponse(
+ TrackerService.mdblist,
+ json.decode(res.body) as Map,
+ );
+ return fresh.copyWith(username: current.username);
+ }
+}
diff --git a/lib/services/trackers/mdblist/mdblist_client.dart b/lib/services/trackers/mdblist/mdblist_client.dart
new file mode 100644
index 00000000..b39be9f4
--- /dev/null
+++ b/lib/services/trackers/mdblist/mdblist_client.dart
@@ -0,0 +1,159 @@
+import 'dart:async';
+
+import 'package:http/http.dart' as http;
+
+import '../../../utils/app_logger.dart';
+import '../future_coalescer.dart';
+import '../tracker.dart';
+import '../tracker_constants.dart';
+import '../tracker_exceptions.dart';
+import '../tracker_http_client.dart';
+import '../tracker_session.dart';
+import 'mdblist_auth_service.dart';
+import 'mdblist_constants.dart';
+
+/// HTTP wrapper for the MDBList REST API.
+///
+/// Access tokens live 30 days and rotate through the refresh grant, so this
+/// mirrors the MAL/Trakt shape: refresh shortly before expiry or on a 401,
+/// with concurrent refreshes coalesced into one in-flight request.
+class MdblistClient implements DisposableTrackerClient {
+ /// `/scrobble/*` answers 201 on success; the sync endpoints answer 200.
+ static const Set _defaultAllowedStatuses = {200, 201, 204};
+
+ TrackerSession _session;
+ final TrackerHttpClient _http;
+ final MdblistAuthService _auth;
+ final void Function() onSessionInvalidated;
+ final void Function(TrackerSession)? onSessionUpdated;
+
+ final _refreshCoalescer = FutureCoalescer();
+
+ MdblistClient(
+ TrackerSession session, {
+ required this.onSessionInvalidated,
+ this.onSessionUpdated,
+ http.Client? httpClient,
+ MdblistAuthService? authService,
+ }) : _session = session,
+ _http = TrackerHttpClient(service: TrackerService.mdblist, logLabel: 'MDBList', httpClient: httpClient),
+ _auth = authService ?? MdblistAuthService();
+
+ TrackerSession get session => _session;
+
+ @override
+ void dispose() {
+ _http.dispose();
+ _auth.dispose();
+ }
+
+ /// Current account. Used to populate the display name.
+ Future