diff --git a/lib/providers/catalog_sources_provider.dart b/lib/providers/catalog_sources_provider.dart index 92740a9f4..d80d304cc 100644 --- a/lib/providers/catalog_sources_provider.dart +++ b/lib/providers/catalog_sources_provider.dart @@ -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 _simkl = _CatalogSourceBinding(SimklCatalogSource.new); + final _CatalogSourceBinding _mdblist = _CatalogSourceBinding( + MdblistCatalogSource.new, + ); final _CatalogSourceBinding _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(); diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index b7cb30e91..43fc43af2 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -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; diff --git a/lib/services/catalog/mdblist_catalog_source.dart b/lib/services/catalog/mdblist_catalog_source.dart new file mode 100644 index 000000000..0343d71d1 --- /dev/null +++ b/lib/services/catalog/mdblist_catalog_source.dart @@ -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 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 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 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 {}; + 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> 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 fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async => + CatalogDetail(item: item); + + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async { + if (kind != MediaKind.movie && kind != MediaKind.show) return null; + return external.hasCatalogIds ? CatalogItemIds.fromExternal(external) : null; + } + + @override + Future 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 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 = { + 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 _itemsFrom(Object? value, MediaKind kind) => [ + for (final entry in flexibleMapList(value)) ?_itemFor(entry, kind), + ]; + + CatalogItem? _itemFor(Map 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 entry) { + final rawIds = entry['ids']; + final ids = rawIds is Map ? rawIds : const {}; + 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(); +} diff --git a/lib/services/trackers/mdblist/mdblist_client.dart b/lib/services/trackers/mdblist/mdblist_client.dart index 2344f19fe..9f5fe8c7a 100644 --- a/lib/services/trackers/mdblist/mdblist_client.dart +++ b/lib/services/trackers/mdblist/mdblist_client.dart @@ -76,6 +76,29 @@ class MdblistClient implements DisposableTrackerClient { return const []; } + Future> 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() : const {}; + } + + Future> 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() : const {}; + } + + Future addToWatchlist(Map body) => _request('POST', '/watchlist/items/add', body: body); + + Future removeFromWatchlist(Map 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 revoke() async { @@ -116,6 +139,7 @@ class MdblistClient implements DisposableTrackerClient { String method, String path, { Map? body, + Map? queryParameters, Set 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 _send(String method, String path, {Map? body}) { - final uri = Uri.parse('${MdblistConstants.apiBase}$path'); + Future _send( + String method, + String path, { + Map? body, + Map? queryParameters, + }) { + final uri = Uri.parse('${MdblistConstants.apiBase}$path').replace(queryParameters: queryParameters); return _http.sendJson( method, uri, diff --git a/test/providers/catalog_sources_provider_test.dart b/test/providers/catalog_sources_provider_test.dart index e8f3bad73..f14997b24 100644 --- a/test/providers/catalog_sources_provider_test.dart +++ b/test/providers/catalog_sources_provider_test.dart @@ -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'); diff --git a/test/providers/trackers_provider_test.dart b/test/providers/trackers_provider_test.dart index ba7539ddc..58b7cbd2d 100644 --- a/test/providers/trackers_provider_test.dart +++ b/test/providers/trackers_provider_test.dart @@ -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(); }); diff --git a/test/screens/explore_screen_test.dart b/test/screens/explore_screen_test.dart index 8db679dd2..9f8b3aff6 100644 --- a/test/screens/explore_screen_test.dart +++ b/test/screens/explore_screen_test.dart @@ -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 { diff --git a/test/services/catalog/mdblist_catalog_source_test.dart b/test/services/catalog/mdblist_catalog_source_test.dart new file mode 100644 index 000000000..1338e8ee3 --- /dev/null +++ b/test/services/catalog/mdblist_catalog_source_test.dart @@ -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 _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 requests; + late List 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); + }); + }); +}