fix(explore): add MDBList watchlist source
Connected MDBList accounts were omitted from Explore, so their watchlists could not be selected or displayed. Register the profile-scoped MDBList client as a catalog source with watchlist mutations and search. close #1888
This commit is contained in:
@@ -16,6 +16,7 @@ import '../services/base_shared_preferences_service.dart';
|
||||
import '../services/catalog/library_watchlist_candidates.dart';
|
||||
import '../services/catalog/catalog_source.dart';
|
||||
import '../services/catalog/anilist_catalog_source.dart';
|
||||
import '../services/catalog/mdblist_catalog_source.dart';
|
||||
import '../services/catalog/mal_catalog_source.dart';
|
||||
import '../services/catalog/plex_catalog_source.dart';
|
||||
import '../services/catalog/seerr_catalog_source.dart';
|
||||
@@ -26,6 +27,7 @@ import '../services/plex_discover_client.dart';
|
||||
import '../services/seerr/seerr_client.dart';
|
||||
import '../services/trackers/anilist/anilist_client.dart';
|
||||
import '../services/trackers/mal/mal_client.dart';
|
||||
import '../services/trackers/mdblist/mdblist_client.dart';
|
||||
import '../services/trackers/simkl/simkl_client.dart';
|
||||
import '../services/trackers/trakt/trakt_client.dart';
|
||||
import 'seerr_account_provider.dart';
|
||||
@@ -109,6 +111,9 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
AnilistCatalogSource.new,
|
||||
);
|
||||
final _CatalogSourceBinding<SimklClient, SimklCatalogSource> _simkl = _CatalogSourceBinding(SimklCatalogSource.new);
|
||||
final _CatalogSourceBinding<MdblistClient, MdblistCatalogSource> _mdblist = _CatalogSourceBinding(
|
||||
MdblistCatalogSource.new,
|
||||
);
|
||||
final _CatalogSourceBinding<SeerrClient, SeerrCatalogSource> _seerr = _CatalogSourceBinding(SeerrCatalogSource.new);
|
||||
int _profileBindingGeneration = 0;
|
||||
int _plexSessionGeneration = 0;
|
||||
@@ -130,6 +135,7 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
?_mal.source,
|
||||
?_anilist.source,
|
||||
?_simkl.source,
|
||||
?_mdblist.source,
|
||||
?_plex.source,
|
||||
?_seerr.source,
|
||||
];
|
||||
@@ -272,6 +278,7 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
changed = _mal.update(trackers.malCatalogClient) || changed;
|
||||
changed = _anilist.update(trackers.anilistCatalogClient) || changed;
|
||||
changed = _simkl.update(trackers.simklCatalogClient) || changed;
|
||||
changed = _mdblist.update(trackers.mdblistCatalogClient) || changed;
|
||||
changed = _seerr.update(seerr.catalogClient) || changed;
|
||||
if (changed) {
|
||||
_invalidateWatchlistCandidates();
|
||||
@@ -285,6 +292,7 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
|
||||
_trakt.dispose();
|
||||
_mal.dispose();
|
||||
_anilist.dispose();
|
||||
_mdblist.dispose();
|
||||
_simkl.dispose();
|
||||
_seerr.dispose();
|
||||
super.dispose();
|
||||
|
||||
@@ -139,11 +139,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// is gated on the provider's profile-bound session.
|
||||
TraktClient? get traktCatalogClient => _trakt.session == null ? null : TraktTracker.instance.client;
|
||||
|
||||
/// Live AniList and Simkl clients for Explore. Like [malCatalogClient],
|
||||
/// these are gated on this provider's profile-bound sessions so a fresh
|
||||
/// profile subtree cannot observe clients still bound to the prior profile.
|
||||
/// Live AniList, Simkl, and MDBList clients for Explore. Like
|
||||
/// [malCatalogClient], these are gated on this provider's profile-bound
|
||||
/// sessions so a fresh profile subtree cannot observe clients still bound
|
||||
/// to the prior profile.
|
||||
AnilistClient? get anilistCatalogClient => _anilist.session == null ? null : AnilistTracker.instance.client;
|
||||
SimklClient? get simklCatalogClient => _simkl.session == null ? null : SimklTracker.instance.client;
|
||||
MdblistClient? get mdblistCatalogClient => _mdblist.session == null ? null : MdblistTracker.instance.client;
|
||||
|
||||
String? get malUsername => _mal.session?.username;
|
||||
String? get anilistUsername => _anilist.session?.username;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../models/catalog/catalog_item.dart';
|
||||
import '../../utils/external_ids.dart';
|
||||
import '../../utils/json_utils.dart';
|
||||
import '../trackers/mdblist/mdblist_client.dart';
|
||||
import 'catalog_source.dart';
|
||||
import 'catalog_watchlist_machinery.dart';
|
||||
|
||||
/// MDBList watchlist/search adapter. The tracker owns [_client].
|
||||
class MdblistCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
|
||||
MdblistCatalogSource(this._client);
|
||||
|
||||
final MdblistClient _client;
|
||||
|
||||
@override
|
||||
CatalogSourceId get id => CatalogSourceId.mdblist;
|
||||
|
||||
@override
|
||||
String get displayName => 'MDBList';
|
||||
|
||||
@override
|
||||
List<CatalogRowId> get supportedRows => const [CatalogRowId.watchlist];
|
||||
|
||||
@override
|
||||
bool get supportsWatchlist => true;
|
||||
|
||||
@override
|
||||
String get watchlistLogLabel => 'MDBList: watchlist';
|
||||
|
||||
@override
|
||||
int get watchlistPageLimit => 250;
|
||||
|
||||
@override
|
||||
int get watchlistMaxPages => 4;
|
||||
|
||||
@override
|
||||
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
|
||||
if (row != CatalogRowId.watchlist) throw ArgumentError('MDBList does not serve ${row.name}');
|
||||
final result = await _fetchWatchlistPage(page, limit);
|
||||
return CatalogPage(items: result.items, hasMore: result.hasMore, totalResults: result.total);
|
||||
}
|
||||
|
||||
Future<({List<CatalogItem> items, bool hasMore, int? total})> _fetchWatchlistPage(int page, int limit) async {
|
||||
final normalizedPage = page < 1 ? 1 : page;
|
||||
final normalizedLimit = limit < 1 ? 1 : limit;
|
||||
final response = await _client.getWatchlist(limit: normalizedLimit, offset: (normalizedPage - 1) * normalizedLimit);
|
||||
final items = [
|
||||
..._itemsFrom(response['movies'], MediaKind.movie),
|
||||
..._itemsFrom(response['shows'], MediaKind.show),
|
||||
];
|
||||
final pagination = response['pagination'];
|
||||
final pageData = pagination is Map ? pagination : const <Object?, Object?>{};
|
||||
final hasMore = flexibleBoolNullable(pageData['has_more']) ?? items.length >= normalizedLimit;
|
||||
final totalMovies = flexibleInt(pageData['total_movies']);
|
||||
final totalShows = flexibleInt(pageData['total_shows']);
|
||||
final total = totalMovies == null && totalShows == null ? null : (totalMovies ?? 0) + (totalShows ?? 0);
|
||||
return (items: items, hasMore: hasMore, total: total);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty || limit <= 0) return const [];
|
||||
final response = await _client.searchCatalog(trimmed, limit: limit);
|
||||
return [
|
||||
for (final entry in flexibleMapList(response['search']))
|
||||
if (_kindFor(entry['type']) case final kind?) ?_itemFor(entry, kind),
|
||||
].take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async =>
|
||||
CatalogDetail(item: item);
|
||||
|
||||
@override
|
||||
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async {
|
||||
if (kind != MediaKind.movie && kind != MediaKind.show) return null;
|
||||
return external.hasCatalogIds ? CatalogItemIds.fromExternal(external) : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WatchlistKeyPage> fetchWatchlistKeyPage(int page, int limit) async {
|
||||
final result = await _fetchWatchlistPage(page, limit);
|
||||
return (groups: [for (final item in result.items) membershipKeysFor(item.kind, item.ids)], hasMore: result.hasMore);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}) async {
|
||||
if (kind != MediaKind.movie && kind != MediaKind.show) {
|
||||
throw ArgumentError('MDBList watchlists only accept movies and shows');
|
||||
}
|
||||
final mutationIds = <String, dynamic>{
|
||||
if (ids.imdb != null) 'imdb': ids.imdb,
|
||||
if (ids.tmdb != null) 'tmdb': ids.tmdb,
|
||||
if (ids.tvdb != null) 'tvdb': ids.tvdb,
|
||||
if (ids.trakt != null) 'trakt': ids.trakt,
|
||||
};
|
||||
if (mutationIds.isEmpty) throw ArgumentError('MDBList watchlist mutation requires an external id');
|
||||
final body = {
|
||||
kind == MediaKind.show ? 'shows' : 'movies': [mutationIds],
|
||||
};
|
||||
if (add) {
|
||||
await _client.addToWatchlist(body);
|
||||
} else {
|
||||
await _client.removeFromWatchlist(body);
|
||||
}
|
||||
}
|
||||
|
||||
List<CatalogItem> _itemsFrom(Object? value, MediaKind kind) => [
|
||||
for (final entry in flexibleMapList(value)) ?_itemFor(entry, kind),
|
||||
];
|
||||
|
||||
CatalogItem? _itemFor(Map<String, dynamic> entry, MediaKind kind) {
|
||||
final title = _string(entry['title']);
|
||||
final ids = _idsFor(entry);
|
||||
if (title == null || !ids.hasAny) return null;
|
||||
final score = flexibleDouble(entry['score']);
|
||||
return CatalogItem(
|
||||
source: CatalogSourceId.mdblist,
|
||||
kind: kind,
|
||||
title: title,
|
||||
year: flexibleInt(entry['year'] ?? entry['release_year']),
|
||||
overview: _string(entry['description']),
|
||||
rating: score == null || score < 0 || score > 100 ? null : score / 10,
|
||||
genres: flexibleStringList(entry['genres']),
|
||||
ids: ids,
|
||||
posterUrl: _string(entry['poster']),
|
||||
addedAt: entry['watchlist_at'] is String ? DateTime.tryParse(entry['watchlist_at'] as String) : null,
|
||||
);
|
||||
}
|
||||
|
||||
CatalogItemIds _idsFor(Map<String, dynamic> entry) {
|
||||
final rawIds = entry['ids'];
|
||||
final ids = rawIds is Map ? rawIds : const <Object?, Object?>{};
|
||||
return CatalogItemIds(
|
||||
trakt: flexibleInt(ids['trakt'] ?? ids['traktid']),
|
||||
imdb: _string(ids['imdb'] ?? ids['imdbid'] ?? entry['imdb_id']),
|
||||
tmdb: flexibleInt(ids['tmdb'] ?? ids['tmdbid'] ?? entry['id']),
|
||||
tvdb: flexibleInt(ids['tvdb'] ?? ids['tvdbid'] ?? entry['tvdb_id']),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _string(Object? value) => value is String && value.isNotEmpty ? value : null;
|
||||
|
||||
static MediaKind? _kindFor(Object? type) => switch (type) {
|
||||
'movie' => MediaKind.movie,
|
||||
'show' || 'series' => MediaKind.show,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() => disposeWatchlistMachinery();
|
||||
}
|
||||
@@ -76,6 +76,29 @@ class MdblistClient implements DisposableTrackerClient {
|
||||
return const [];
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getWatchlist({int limit = 100, int offset = 0}) async {
|
||||
final res = await _request(
|
||||
'GET',
|
||||
'/watchlist/items',
|
||||
queryParameters: {
|
||||
'limit': '$limit',
|
||||
if (offset > 0) 'offset': '$offset',
|
||||
'append_to_response': 'genres,poster,description',
|
||||
},
|
||||
);
|
||||
return res is Map ? res.cast<String, dynamic>() : const {};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> searchCatalog(String query, {int limit = 30}) async {
|
||||
final res = await _request('GET', '/search/any', queryParameters: {'query': query, 'limit': '$limit'});
|
||||
return res is Map ? res.cast<String, dynamic>() : const {};
|
||||
}
|
||||
|
||||
Future<void> addToWatchlist(Map<String, dynamic> body) => _request('POST', '/watchlist/items/add', body: body);
|
||||
|
||||
Future<void> removeFromWatchlist(Map<String, dynamic> body) =>
|
||||
_request('POST', '/watchlist/items/remove', body: body);
|
||||
|
||||
/// 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 {
|
||||
@@ -116,6 +139,7 @@ class MdblistClient implements DisposableTrackerClient {
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? queryParameters,
|
||||
Set<int> allowStatuses = _defaultAllowedStatuses,
|
||||
}) async {
|
||||
if (_session.needsRefresh) {
|
||||
@@ -126,12 +150,12 @@ class MdblistClient implements DisposableTrackerClient {
|
||||
}
|
||||
}
|
||||
|
||||
var res = await _send(method, path, body: body);
|
||||
var res = await _send(method, path, body: body, queryParameters: queryParameters);
|
||||
|
||||
if (res.statusCode == 401) {
|
||||
// A failed refresh propagates its TrackerAuthException, matching Trakt.
|
||||
await _refresh();
|
||||
res = await _send(method, path, body: body);
|
||||
res = await _send(method, path, body: body, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
if (allowStatuses.contains(res.statusCode)) return TrackerHttpClient.decodeJson(res.body);
|
||||
@@ -146,8 +170,13 @@ class MdblistClient implements DisposableTrackerClient {
|
||||
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');
|
||||
Future<http.Response> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
final uri = Uri.parse('${MdblistConstants.apiBase}$path').replace(queryParameters: queryParameters);
|
||||
return _http.sendJson(
|
||||
method,
|
||||
uri,
|
||||
|
||||
@@ -5,8 +5,16 @@ import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/providers/catalog_sources_provider.dart';
|
||||
import 'package:plezy/providers/seerr_account_provider.dart';
|
||||
import 'package:plezy/providers/trackers_provider.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/plex_discover_client.dart';
|
||||
import 'package:plezy/services/trackers/mdblist/mdblist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_account_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
import '../test_helpers/media_items.dart';
|
||||
@@ -85,6 +93,46 @@ void main() {
|
||||
expect(calls, 3);
|
||||
expect(provider.connectedSources, isEmpty);
|
||||
});
|
||||
test('tracker rebind exposes and removes the MDBList catalog source', () async {
|
||||
const userUuid = 'profile-mdblist';
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
await trackerAccountStore(TrackerService.mdblist).save(
|
||||
userUuid,
|
||||
TrackerSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: now + 3600,
|
||||
createdAt: now,
|
||||
username: 'alice',
|
||||
),
|
||||
);
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final trackers = TrackersProvider();
|
||||
final seerr = SeerrAccountProvider();
|
||||
final sources = CatalogSourcesProvider(plexSessionSupplier: () async => null);
|
||||
addTearDown(() {
|
||||
sources.dispose();
|
||||
trackers.dispose();
|
||||
seerr.dispose();
|
||||
MdblistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
});
|
||||
|
||||
await trackers.onActiveProfileChanged(userUuid);
|
||||
await TrackerCoordinator.instance.flushWriteQueue();
|
||||
sources.update(trackers, seerr);
|
||||
|
||||
expect(sources.connectedSources.map((source) => source.id), [CatalogSourceId.mdblist]);
|
||||
expect(sources.activeSource?.displayName, 'MDBList');
|
||||
expect(sources.watchlistCapableSource?.id, CatalogSourceId.mdblist);
|
||||
|
||||
await trackers.onActiveProfileChanged('empty-profile');
|
||||
await TrackerCoordinator.instance.flushWriteQueue();
|
||||
sources.update(trackers, seerr);
|
||||
|
||||
expect(sources.connectedSources, isEmpty);
|
||||
expect(sources.hasAnySource, isFalse);
|
||||
});
|
||||
|
||||
group('watchlist candidate cache', () {
|
||||
final item = testMediaItem(id: 'movie-1', serverId: 'server-1');
|
||||
|
||||
@@ -76,6 +76,7 @@ void main() {
|
||||
expect(p.isSimklConnected, isFalse);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(p.isMdblistConnected, isFalse);
|
||||
expect(p.mdblistCatalogClient, isNull);
|
||||
expect(p.malUsername, isNull);
|
||||
expect(p.anilistUsername, isNull);
|
||||
expect(p.simklUsername, isNull);
|
||||
@@ -121,6 +122,7 @@ void main() {
|
||||
await _simklStore.save(uuid, _simkl(username: 'carol'));
|
||||
await _traktStore.save(uuid, _trakt(username: 'dave'));
|
||||
|
||||
await _mdblistStore.save(uuid, _session(TrackerService.mdblist, 'eve'));
|
||||
// Reset cached singletons so the provider reads fresh prefs state.
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
@@ -133,10 +135,13 @@ void main() {
|
||||
expect(p.isAnilistConnected, isTrue);
|
||||
expect(p.isSimklConnected, isTrue);
|
||||
expect(p.isTraktConnected, isTrue);
|
||||
expect(p.isMdblistConnected, isTrue);
|
||||
expect(p.malUsername, 'alice');
|
||||
expect(p.anilistUsername, 'bob');
|
||||
expect(p.simklUsername, 'carol');
|
||||
expect(p.traktUsername, 'dave');
|
||||
expect(p.mdblistUsername, 'eve');
|
||||
expect(p.mdblistCatalogClient, same(MdblistTracker.instance.client));
|
||||
expect(notified, greaterThanOrEqualTo(1));
|
||||
|
||||
p.dispose();
|
||||
@@ -148,6 +153,7 @@ void main() {
|
||||
await _anilistStore.save(uuid, _anilist(username: 'bob'));
|
||||
await _simklStore.save(uuid, _simkl(username: 'carol'));
|
||||
await _traktStore.save(uuid, _trakt(username: 'dave'));
|
||||
await _mdblistStore.save(uuid, _session(TrackerService.mdblist, 'eve'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
@@ -159,6 +165,8 @@ void main() {
|
||||
expect(p.isAnilistConnected, isFalse);
|
||||
expect(p.isSimklConnected, isFalse);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(p.isMdblistConnected, isFalse);
|
||||
expect(p.mdblistCatalogClient, isNull);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
@@ -180,15 +180,16 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore(
|
||||
final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId);
|
||||
final anilist = _FakeCatalogSource(CatalogSourceId.anilist, 'AniList', 3);
|
||||
final simkl = _FakeCatalogSource(CatalogSourceId.simkl, 'Simkl', 4);
|
||||
final mdblist = _FakeCatalogSource(CatalogSourceId.mdblist, 'MDBList', 5);
|
||||
final plex = _FakeCatalogSource(
|
||||
CatalogSourceId.plex,
|
||||
'Plex',
|
||||
5,
|
||||
6,
|
||||
providerHubTitle: 'Trending on Plex',
|
||||
providerHubStyle: plexHubStyle,
|
||||
);
|
||||
final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 6);
|
||||
final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, plex, seerr]);
|
||||
final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 7);
|
||||
final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, mdblist, plex, seerr]);
|
||||
final explore = ExploreProvider(sources);
|
||||
addTearDown(explore.dispose);
|
||||
addTearDown(sources.dispose);
|
||||
@@ -196,6 +197,7 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore(
|
||||
addTearDown(mal.dispose);
|
||||
addTearDown(anilist.dispose);
|
||||
addTearDown(simkl.dispose);
|
||||
addTearDown(mdblist.dispose);
|
||||
addTearDown(plex.dispose);
|
||||
addTearDown(seerr.dispose);
|
||||
|
||||
@@ -299,16 +301,16 @@ void main() {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (final name in ['Trakt', 'MyAnimeList', 'AniList', 'Simkl', 'Plex', 'Seerr']) {
|
||||
for (final name in ['Trakt', 'MyAnimeList', 'AniList', 'Simkl', 'MDBList', 'Plex', 'Seerr']) {
|
||||
expect(find.text(name), findsAtLeast(1));
|
||||
}
|
||||
expect(find.byType(CatalogSourceLogo), findsAtLeast(6));
|
||||
expect(find.byType(CatalogSourceLogo), findsAtLeast(7));
|
||||
|
||||
await tester.tap(find.text('AniList'));
|
||||
await tester.tap(find.text('MDBList'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.activeSource?.id, CatalogSourceId.anilist);
|
||||
expect(find.text('AniList'), findsOneWidget);
|
||||
expect(find.text('AniList Movie'), findsAtLeast(1));
|
||||
expect(sources.activeSource?.id, CatalogSourceId.mdblist);
|
||||
expect(find.text('MDBList'), findsOneWidget);
|
||||
expect(find.text('MDBList Movie'), findsAtLeast(1));
|
||||
});
|
||||
|
||||
testWidgets('a null-style Plex provider hub keeps the existing Explore shelf', (tester) async {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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/catalog/catalog_item.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/mdblist_catalog_source.dart';
|
||||
import 'package:plezy/services/trackers/mdblist/mdblist_client.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
|
||||
TrackerSession _session() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return TrackerSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: now + 86400,
|
||||
createdAt: now - 3600,
|
||||
username: 'alice',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _watchlistBody() => {
|
||||
'movies': [
|
||||
{
|
||||
'id': 603,
|
||||
'title': 'The Matrix',
|
||||
'imdb_id': 'tt0133093',
|
||||
'release_year': 1999,
|
||||
'watchlist_at': '2026-01-03T12:34:56Z',
|
||||
'description': 'A hacker discovers the truth.',
|
||||
'genres': ['action', 'science-fiction'],
|
||||
'poster': 'https://image.tmdb.org/matrix.jpg',
|
||||
'ids': {'imdb': 'tt0133093', 'tmdb': 603},
|
||||
},
|
||||
],
|
||||
'shows': [
|
||||
{
|
||||
'id': 95396,
|
||||
'title': 'Severance',
|
||||
'imdb_id': 'tt11280740',
|
||||
'tvdb_id': 371980,
|
||||
'release_year': 2022,
|
||||
'ids': {'imdb': 'tt11280740', 'tmdb': 95396, 'tvdb': 371980},
|
||||
},
|
||||
],
|
||||
'pagination': {'total_movies': 1, 'total_shows': 1, 'has_more': false},
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('MdblistCatalogSource', () {
|
||||
late List<http.Request> requests;
|
||||
late List<http.Response Function(http.Request)> handlers;
|
||||
late MdblistClient client;
|
||||
late MdblistCatalogSource source;
|
||||
|
||||
setUp(() {
|
||||
requests = [];
|
||||
handlers = [];
|
||||
client = MdblistClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => fail('should not invalidate'),
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (handlers.isNotEmpty) return handlers.removeAt(0)(request);
|
||||
return http.Response(json.encode(_watchlistBody()), 200);
|
||||
}),
|
||||
);
|
||||
source = MdblistCatalogSource(client);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
source.dispose();
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('maps the authenticated watchlist and uses offset pagination', () async {
|
||||
final page = await source.fetchRow(CatalogRowId.watchlist, page: 2, limit: 25);
|
||||
|
||||
final request = requests.single;
|
||||
expect(request.url.path, '/watchlist/items');
|
||||
expect(request.url.queryParameters['offset'], '25');
|
||||
expect(request.url.queryParameters['append_to_response'], 'genres,poster,description');
|
||||
expect(page.totalResults, 2);
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(page.items, hasLength(2));
|
||||
|
||||
final movie = page.items.first;
|
||||
expect(movie.source, CatalogSourceId.mdblist);
|
||||
expect(movie.kind, MediaKind.movie);
|
||||
expect(movie.ids.imdb, 'tt0133093');
|
||||
expect(movie.ids.tmdb, 603);
|
||||
expect(movie.overview, 'A hacker discovers the truth.');
|
||||
expect(movie.genres, ['action', 'science-fiction']);
|
||||
expect(movie.posterUrl, 'https://image.tmdb.org/matrix.jpg');
|
||||
expect(movie.addedAt, DateTime.utc(2026, 1, 3, 12, 34, 56));
|
||||
expect(page.items.last.kind, MediaKind.show);
|
||||
});
|
||||
|
||||
test('loads membership and sends add and remove requests', () async {
|
||||
await source.ensureWatchlistLoaded();
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isTrue);
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(imdb: 'tt11280740')), isTrue);
|
||||
|
||||
requests.clear();
|
||||
handlers.add((_) => http.Response('{}', 200));
|
||||
await source.addToWatchlist(MediaKind.show, const CatalogItemIds(imdb: 'tt0903747', tmdb: 1396));
|
||||
expect(requests.single.url.path, '/watchlist/items/add');
|
||||
expect(json.decode(requests.single.body), {
|
||||
'shows': [
|
||||
{'imdb': 'tt0903747', 'tmdb': 1396},
|
||||
],
|
||||
});
|
||||
|
||||
requests.clear();
|
||||
handlers.add((_) => http.Response('{}', 200));
|
||||
await source.removeFromWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt0133093', tmdb: 603));
|
||||
expect(requests.single.url.path, '/watchlist/items/remove');
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isFalse);
|
||||
});
|
||||
|
||||
test('search maps movies and shows and ignores unsupported media', () async {
|
||||
handlers.add(
|
||||
(_) => http.Response(
|
||||
json.encode({
|
||||
'search': [
|
||||
{
|
||||
'title': 'Jaws',
|
||||
'year': 1975,
|
||||
'score': 86,
|
||||
'type': 'movie',
|
||||
'ids': {'imdbid': 'tt0073195', 'tmdbid': 578},
|
||||
},
|
||||
{
|
||||
'title': 'Unsupported episode',
|
||||
'type': 'episode',
|
||||
'ids': {'tmdbid': 1},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
final results = await source.search(' jaws ');
|
||||
|
||||
expect(requests.single.url.path, '/search/any');
|
||||
expect(requests.single.url.queryParameters['query'], 'jaws');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.single.title, 'Jaws');
|
||||
expect(results.single.ids.imdb, 'tt0073195');
|
||||
expect(results.single.rating, 8.6);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user