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:
edde746
2026-08-18 13:13:06 +02:00
parent 304d8a5e33
commit 9fe6dbe9f2
8 changed files with 422 additions and 16 deletions
@@ -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();
+5 -3
View File
@@ -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();
});
+11 -9
View File
@@ -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);
});
});
}