feat(explore): add AniList, Simkl, and Plex catalogs
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
import 'dart:async';
|
||||
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/anilist/anilist_media.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
|
||||
import 'package:plezy/services/catalog/anilist_catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_client.dart';
|
||||
import 'package:plezy/services/trackers/fribb_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
TrackerSession _session() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return TrackerSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: null,
|
||||
expiresAt: now + 86400,
|
||||
scope: null,
|
||||
createdAt: now - 3600,
|
||||
username: 'alice',
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeFribb implements FribbMappingLookup {
|
||||
final List<FribbMappingRow> rows;
|
||||
|
||||
_FakeFribb(this.rows);
|
||||
|
||||
@override
|
||||
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => [
|
||||
for (final row in rows)
|
||||
if ((tvdbId != null && row.tvdbId == tvdbId) ||
|
||||
(tmdbId != null && (row.tmdbIds?.contains(tmdbId) ?? false)) ||
|
||||
(imdbId != null && (row.imdbIds?.contains(imdbId) ?? false)))
|
||||
row,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<FribbMappingRow?> lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _media({
|
||||
required int id,
|
||||
int? idMal,
|
||||
String title = 'Attack on Titan',
|
||||
String format = 'TV',
|
||||
String status = 'RELEASING',
|
||||
bool isAdult = false,
|
||||
}) => {
|
||||
'id': id,
|
||||
'idMal': ?idMal,
|
||||
'title': {'english': title, 'romaji': 'Shingeki no Kyojin', 'userPreferred': 'Preferred'},
|
||||
'format': format,
|
||||
'status': status,
|
||||
'episodes': 25,
|
||||
'duration': 24,
|
||||
'description': '<b>Humanity</b><br>fights & survives.',
|
||||
'averageScore': 84,
|
||||
'season': 'SPRING',
|
||||
'seasonYear': 2013,
|
||||
'startDate': {'year': 2013},
|
||||
'genres': ['Action', 'Drama'],
|
||||
'isAdult': isAdult,
|
||||
'coverImage': {'extraLarge': 'https://img.anilist.co/poster/$id.jpg'},
|
||||
'bannerImage': 'https://img.anilist.co/banner/$id.jpg',
|
||||
'studios': {
|
||||
'nodes': [
|
||||
{'name': 'Wit Studio'},
|
||||
],
|
||||
},
|
||||
'trailer': {'id': 'abc123', 'site': 'youtube'},
|
||||
};
|
||||
|
||||
http.Response _data(Map<String, dynamic> data, {int status = 200, Map<String, String>? headers}) =>
|
||||
http.Response(json.encode({'data': data}), status, headers: {'content-type': 'application/json', ...?headers});
|
||||
|
||||
Map<String, dynamic> _requestBody(http.Request request) => json.decode(request.body) as Map<String, dynamic>;
|
||||
|
||||
void main() {
|
||||
const season1 = FribbMappingRow(
|
||||
anilistId: 16498,
|
||||
malId: 16498,
|
||||
tvdbId: 267440,
|
||||
tvdbSeason: 1,
|
||||
imdbIds: ['tt2560140'],
|
||||
);
|
||||
const season3 = FribbMappingRow(
|
||||
anilistId: 35760,
|
||||
malId: 35760,
|
||||
tvdbId: 267440,
|
||||
tvdbSeason: 3,
|
||||
imdbIds: ['tt2560140'],
|
||||
);
|
||||
const movie = FribbMappingRow(
|
||||
anilistId: 21519,
|
||||
malId: 32281,
|
||||
tmdbIds: [372058],
|
||||
imdbIds: ['tt5311514'],
|
||||
type: 'MOVIE',
|
||||
);
|
||||
|
||||
group('AnilistMedia', () {
|
||||
test('parses requested fields and strips AniList HTML', () {
|
||||
final media = AnilistMedia.fromJson(_media(id: 1, idMal: 16498));
|
||||
|
||||
expect(media.displayTitle, 'Attack on Titan');
|
||||
expect(media.description, 'Humanity\nfights & survives.');
|
||||
expect(media.year, 2013);
|
||||
expect(media.posterUrl, 'https://img.anilist.co/poster/1.jpg');
|
||||
expect(media.backdropUrl, 'https://img.anilist.co/banner/1.jpg');
|
||||
expect(media.rating, 8.4);
|
||||
expect(media.runtimeMinutes, 24);
|
||||
expect(media.network, 'Wit Studio');
|
||||
expect(media.trailerUrl, 'https://www.youtube.com/watch?v=abc123');
|
||||
expect(media.isMovie, isFalse);
|
||||
});
|
||||
|
||||
test('stripHtml handles line breaks, tags, entities, and empty input', () {
|
||||
expect(
|
||||
AnilistMedia.stripHtml('<i>A "title"</i><br />B < C > D 'x''),
|
||||
'A "title"\nB < C > D \'x\'',
|
||||
);
|
||||
expect(AnilistMedia.stripHtml('<b></b>'), isNull);
|
||||
expect(AnilistMedia.stripHtml(null), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnilistCatalogSource', () {
|
||||
late List<http.Request> requests;
|
||||
late FutureOr<http.Response> Function(http.Request request) responder;
|
||||
late AnilistClient client;
|
||||
late AnilistCatalogSource source;
|
||||
|
||||
setUp(() {
|
||||
requests = [];
|
||||
responder = (request) {
|
||||
final body = _requestBody(request);
|
||||
final query = body['query'] as String;
|
||||
if (query.contains('Viewer { id }')) {
|
||||
return _data({
|
||||
'Viewer': {'id': 7},
|
||||
});
|
||||
}
|
||||
if (query.contains('MediaListCollection')) {
|
||||
return _data({
|
||||
'MediaListCollection': {
|
||||
'hasNextChunk': false,
|
||||
'lists': [
|
||||
{
|
||||
'isCustomList': false,
|
||||
'entries': [
|
||||
{'media': _media(id: 16498, idMal: 16498)},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return _data({
|
||||
'Page': {
|
||||
'pageInfo': {'hasNextPage': false},
|
||||
'media': [_media(id: 16498, idMal: 16498)],
|
||||
},
|
||||
});
|
||||
};
|
||||
client = AnilistClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => fail('should not invalidate'),
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
return responder(request);
|
||||
}),
|
||||
);
|
||||
source = AnilistCatalogSource(client, fribb: _FakeFribb(const [season1, season3, movie]));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
source.dispose();
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('trending query clamps page size and enriches every external id', () async {
|
||||
responder = (request) {
|
||||
final body = _requestBody(request);
|
||||
final variables = body['variables'] as Map<String, dynamic>;
|
||||
expect((body['query'] as String), contains('isAdult: false'));
|
||||
expect(variables['sort'], ['TRENDING_DESC']);
|
||||
expect(variables['perPage'], 50);
|
||||
return _data({
|
||||
'Page': {
|
||||
'pageInfo': {'hasNextPage': true},
|
||||
'media': [_media(id: 16498, idMal: 16498)],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.trendingAnime, limit: 500);
|
||||
|
||||
expect(page.hasMore, isTrue);
|
||||
expect(page.items, hasLength(1));
|
||||
final item = page.items.single;
|
||||
expect(item.source, CatalogSourceId.anilist);
|
||||
expect(item.ids.anilist, 16498);
|
||||
expect(item.ids.mal, 16498);
|
||||
expect(item.ids.tvdb, 267440);
|
||||
expect(item.ids.imdb, 'tt2560140');
|
||||
expect(item.overview, 'Humanity\nfights & survives.');
|
||||
expect(item.airStatus, CatalogAirStatus.airing);
|
||||
expect(item.episodeCount, 25);
|
||||
});
|
||||
|
||||
test('seasonal client sends season and year variables', () async {
|
||||
responder = (request) {
|
||||
final variables = _requestBody(request)['variables'] as Map<String, dynamic>;
|
||||
expect(variables['season'], 'SPRING');
|
||||
expect(variables['seasonYear'], 2026);
|
||||
expect(variables['sort'], ['POPULARITY_DESC']);
|
||||
return _data({
|
||||
'Page': {
|
||||
'pageInfo': {'hasNextPage': false},
|
||||
'media': <Map<String, dynamic>>[],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
final page = await client.getSeasonalAnime('SPRING', 2026);
|
||||
expect(page.items, isEmpty);
|
||||
});
|
||||
|
||||
test('currentAnimeSeason handles December rollover and season boundaries', () {
|
||||
expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2025, 12, 1)), (season: 'WINTER', year: 2026));
|
||||
expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2026, 1, 1)), (season: 'WINTER', year: 2026));
|
||||
expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2026, 4, 1)), (season: 'SPRING', year: 2026));
|
||||
expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2026, 7, 1)), (season: 'SUMMER', year: 2026));
|
||||
expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2026, 10, 1)), (season: 'FALL', year: 2026));
|
||||
});
|
||||
|
||||
test('planning row caches viewer id, skips custom lists, and deduplicates media', () async {
|
||||
var viewerRequests = 0;
|
||||
responder = (request) {
|
||||
final query = _requestBody(request)['query'] as String;
|
||||
if (query.contains('Viewer { id }')) {
|
||||
viewerRequests++;
|
||||
return _data({
|
||||
'Viewer': {'id': 7},
|
||||
});
|
||||
}
|
||||
expect(query, contains('status: PLANNING'));
|
||||
return _data({
|
||||
'MediaListCollection': {
|
||||
'hasNextChunk': true,
|
||||
'lists': [
|
||||
{
|
||||
'isCustomList': false,
|
||||
'entries': [
|
||||
{'media': _media(id: 16498, idMal: 16498)},
|
||||
{'media': _media(id: 16498, idMal: 16498)},
|
||||
],
|
||||
},
|
||||
{
|
||||
'isCustomList': true,
|
||||
'entries': [
|
||||
{'media': _media(id: 999, idMal: 999)},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
final first = await source.fetchRow(CatalogRowId.watchlist);
|
||||
final second = await source.fetchRow(CatalogRowId.watchlist);
|
||||
|
||||
expect(viewerRequests, 1);
|
||||
expect(first.items.map((item) => item.ids.anilist), [16498]);
|
||||
expect(first.hasMore, isTrue);
|
||||
expect(second.items, hasLength(1));
|
||||
});
|
||||
|
||||
test('planning ids query sends a valid GraphQL field selection', () async {
|
||||
await client.getPlanningIdsPage(7);
|
||||
|
||||
final query = _requestBody(requests.single)['query'] as String;
|
||||
expect(query, contains('id idMal'));
|
||||
expect(query, isNot(contains(r'id\nidMal')));
|
||||
});
|
||||
|
||||
test('unsupported rows throw instead of silently returning empty', () {
|
||||
expect(() => source.fetchRow(CatalogRowId.recommendedMovies), throwsA(isA<ArgumentError>()));
|
||||
});
|
||||
|
||||
test('one-character search requests AniList while whitespace-only does not', () async {
|
||||
final empty = await source.search(' ');
|
||||
expect(empty, isEmpty);
|
||||
expect(requests, isEmpty);
|
||||
|
||||
await source.search(' a ');
|
||||
expect(requests, hasLength(1));
|
||||
final variables = _requestBody(requests.single)['variables'] as Map<String, dynamic>;
|
||||
expect(variables['search'], 'a');
|
||||
});
|
||||
|
||||
test('watchlist snapshot matches the MAL identity form alone', () async {
|
||||
await source.ensureWatchlistLoaded();
|
||||
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(mal: 16498)), isTrue);
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(anilist: 999)), isFalse);
|
||||
});
|
||||
|
||||
test('resolveItemIds prefers season one for shows and movie rows for movies', () async {
|
||||
final showIds = await source.resolveItemIds(MediaKind.show, const ExternalIds(tvdb: 267440, imdb: 'tt2560140'));
|
||||
final movieIds = await source.resolveItemIds(MediaKind.movie, const ExternalIds(tmdb: 372058, imdb: 'tt5311514'));
|
||||
|
||||
expect(showIds?.anilist, 16498);
|
||||
expect(showIds?.mal, 16498);
|
||||
expect(movieIds?.anilist, 21519);
|
||||
expect(movieIds?.mal, 32281);
|
||||
});
|
||||
|
||||
test('resolveItemIds returns null when the matching row lacks AniList id', () async {
|
||||
final noAniListSource = AnilistCatalogSource(
|
||||
client,
|
||||
fribb: _FakeFribb(const [FribbMappingRow(malId: 1, tvdbId: 2)]),
|
||||
);
|
||||
addTearDown(noAniListSource.dispose);
|
||||
|
||||
expect(await noAniListSource.resolveItemIds(MediaKind.show, const ExternalIds(tvdb: 2)), isNull);
|
||||
});
|
||||
|
||||
test('add writes PLANNING without a progress field', () async {
|
||||
responder = (request) {
|
||||
final body = _requestBody(request);
|
||||
final query = body['query'] as String;
|
||||
final variables = body['variables'] as Map<String, dynamic>;
|
||||
expect(query, contains('SaveMediaListEntry'));
|
||||
expect(query, isNot(contains('progress')));
|
||||
expect(variables, {'mediaId': 16498, 'status': 'PLANNING'});
|
||||
return _data({
|
||||
'SaveMediaListEntry': {'id': 1},
|
||||
});
|
||||
};
|
||||
|
||||
await source.addToWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498));
|
||||
expect(requests, hasLength(1));
|
||||
});
|
||||
|
||||
test('remove is a no-op when the media-list entry is already absent', () async {
|
||||
responder = (request) {
|
||||
final query = _requestBody(request)['query'] as String;
|
||||
expect(query, contains('mediaListEntry'));
|
||||
return _data({
|
||||
'Media': {'mediaListEntry': null},
|
||||
});
|
||||
};
|
||||
|
||||
await source.removeFromWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498));
|
||||
expect(requests, hasLength(1));
|
||||
});
|
||||
|
||||
test('failed mutation restores optimistic watchlist membership', () async {
|
||||
responder = (request) {
|
||||
final query = _requestBody(request)['query'] as String;
|
||||
if (query.contains('Viewer { id }')) {
|
||||
return _data({
|
||||
'Viewer': {'id': 7},
|
||||
});
|
||||
}
|
||||
if (query.contains('MediaListCollection')) {
|
||||
return _data({
|
||||
'MediaListCollection': {
|
||||
'hasNextChunk': false,
|
||||
'lists': [
|
||||
{
|
||||
'isCustomList': false,
|
||||
'entries': [
|
||||
{
|
||||
'media': {'id': 16498, 'idMal': 16498},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (query.contains('mediaListEntry')) {
|
||||
return _data({
|
||||
'Media': {
|
||||
'mediaListEntry': {'id': 99},
|
||||
},
|
||||
});
|
||||
}
|
||||
return http.Response('failed', 500);
|
||||
};
|
||||
await source.ensureWatchlistLoaded();
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)), isTrue);
|
||||
|
||||
await expectLater(
|
||||
source.removeFromWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)),
|
||||
throwsA(isA<TrackerApiException>()),
|
||||
);
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)), isTrue);
|
||||
});
|
||||
|
||||
test('cast and related map characters and enriched recommendations', () async {
|
||||
responder = (request) {
|
||||
final query = _requestBody(request)['query'] as String;
|
||||
if (query.contains('characters(')) {
|
||||
return _data({
|
||||
'Media': {
|
||||
'characters': {
|
||||
'edges': [
|
||||
{
|
||||
'role': 'MAIN',
|
||||
'node': {
|
||||
'name': {'full': 'Mikasa Ackerman'},
|
||||
'image': {'large': 'https://img.anilist.co/mikasa.jpg'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return _data({
|
||||
'Media': {
|
||||
'recommendations': {
|
||||
'nodes': [
|
||||
{'mediaRecommendation': _media(id: 21519, idMal: 32281, title: 'Your Name.', format: 'MOVIE')},
|
||||
{'mediaRecommendation': _media(id: 999, title: 'Adult', isAdult: true)},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.anilist,
|
||||
kind: MediaKind.show,
|
||||
title: 'Attack on Titan',
|
||||
ids: CatalogItemIds(anilist: 16498),
|
||||
);
|
||||
|
||||
final cast = await source.fetchCast(item);
|
||||
final related = await source.fetchRelated(item);
|
||||
|
||||
expect(cast.single.name, 'Mikasa Ackerman');
|
||||
expect(cast.single.secondary, 'MAIN');
|
||||
expect(related, hasLength(1));
|
||||
expect(related.single.kind, MediaKind.movie);
|
||||
expect(related.single.ids.tmdb, 372058);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnilistClient 429 handling', () {
|
||||
test('throws the shared rate-limit exception without blocking or retrying', () async {
|
||||
var calls = 0;
|
||||
final client = AnilistClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => fail('should not invalidate'),
|
||||
httpClient: MockClient((request) async {
|
||||
calls++;
|
||||
return http.Response('limited', 429, headers: {'retry-after': '60'});
|
||||
}),
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
await expectLater(
|
||||
client.getViewerId(),
|
||||
throwsA(
|
||||
isA<TrackerRateLimitException>()
|
||||
.having((error) => error.service, 'service', TrackerService.anilist)
|
||||
.having((error) => error.retryAfterSeconds, 'retryAfterSeconds', 60),
|
||||
),
|
||||
);
|
||||
expect(calls, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/plex_catalog_source.dart';
|
||||
import 'package:plezy/services/plex_discover_client.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id');
|
||||
|
||||
http.Response _json(Object body, [int status = 200]) =>
|
||||
http.Response(jsonEncode(body), status, headers: const {'content-type': 'application/json'});
|
||||
|
||||
Map<String, Object?> _metadata({
|
||||
String ratingKey = 'plex-movie-1',
|
||||
String type = 'movie',
|
||||
String title = 'Inception',
|
||||
}) => {
|
||||
'ratingKey': ratingKey,
|
||||
'guid': 'plex://$type/$ratingKey',
|
||||
'type': type,
|
||||
'title': title,
|
||||
'year': 2010,
|
||||
'summary': 'A dream within a dream.',
|
||||
'duration': 8880000,
|
||||
'rating': 8.7,
|
||||
'contentRating': 'PG-13',
|
||||
'thumb': 'https://metadata-static.plex.tv/poster.jpg',
|
||||
'art': 'https://metadata-static.plex.tv/art.jpg',
|
||||
'Genre': [
|
||||
{'tag': 'Science Fiction'},
|
||||
],
|
||||
'Guid': [
|
||||
{'id': 'imdb://tt1375666'},
|
||||
{'id': 'tmdb://27205'},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('PlexCatalogSource', () {
|
||||
test('watchlist uses offset paging and maps Plex metadata', () async {
|
||||
late http.Request captured;
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
captured = request;
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'offset': 25,
|
||||
'size': 1,
|
||||
'totalSize': 27,
|
||||
'Metadata': [_metadata()],
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.watchlist, page: 2, limit: 25);
|
||||
|
||||
expect(captured.method, 'GET');
|
||||
expect(captured.url.path, '/library/sections/watchlist/all');
|
||||
expect(captured.url.queryParameters['X-Plex-Container-Start'], '25');
|
||||
expect(captured.url.queryParameters['X-Plex-Container-Size'], '25');
|
||||
expect(captured.url.queryParameters['includeMeta'], '1');
|
||||
expect(captured.headers['X-Plex-Token'], 'profile-token');
|
||||
expect(captured.headers['X-Plex-Client-Identifier'], 'client-id');
|
||||
expect(page.hasMore, isTrue);
|
||||
|
||||
final item = page.items.single;
|
||||
expect(item.source, CatalogSourceId.plex);
|
||||
expect(item.kind, MediaKind.movie);
|
||||
expect(item.title, 'Inception');
|
||||
expect(item.runtimeMinutes, 148);
|
||||
expect(item.ids.plex, 'plex-movie-1');
|
||||
expect(item.ids.imdb, 'tt1375666');
|
||||
expect(item.ids.tmdb, 27205);
|
||||
expect(item.genres, ['Science Fiction']);
|
||||
});
|
||||
|
||||
test('recommendation hubs retain Plex titles and support View All paging', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.path == '/hubs/sections/watchlist') {
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
{
|
||||
'hubIdentifier': 'because-watchlisted',
|
||||
'key': '/hubs/sections/watchlist/because-watchlisted?source=watchlist',
|
||||
'title': 'Because You Watchlisted Inception',
|
||||
'totalSize': 4,
|
||||
'more': 1,
|
||||
'Metadata': [
|
||||
_metadata(),
|
||||
_metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
|
||||
{'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'hubIdentifier': 'people-only',
|
||||
'key': '/hubs/sections/watchlist/people-only',
|
||||
'title': 'People',
|
||||
'Metadata': [
|
||||
{'ratingKey': 'person-2', 'type': 'person', 'title': 'Another Person'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.url.path == '/hubs/sections/watchlist/because-watchlisted') {
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'offset': 2,
|
||||
'totalSize': 3,
|
||||
'Metadata': [_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar')],
|
||||
},
|
||||
});
|
||||
}
|
||||
return _json({'error': 'unexpected'}, 500);
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
final hubs = await source.fetchHubs(limit: 2);
|
||||
|
||||
expect(requests.first.url.queryParameters, containsPair('count', '3'));
|
||||
expect(requests.first.url.queryParameters, containsPair('includeMeta', '1'));
|
||||
expect(hubs, hasLength(1));
|
||||
expect(hubs.single.id, 'because-watchlisted');
|
||||
expect(hubs.single.title, 'Because You Watchlisted Inception');
|
||||
expect(hubs.single.page.items.map((item) => item.title), ['Inception', 'Severance']);
|
||||
expect(hubs.single.page.hasMore, isTrue);
|
||||
|
||||
final page = await source.fetchHub(hubs.single.id, page: 2, limit: 2);
|
||||
|
||||
expect(requests.last.url.queryParameters, containsPair('source', 'watchlist'));
|
||||
expect(requests.last.url.queryParameters, containsPair('X-Plex-Container-Start', '2'));
|
||||
expect(requests.last.url.queryParameters, containsPair('X-Plex-Container-Size', '2'));
|
||||
expect(page.items.single.title, 'Interstellar');
|
||||
expect(page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('a vanished recommendation hub degrades to an empty page', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
return _json({'error': 'unexpected'}, 500);
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
final page = await source.fetchHub('no-longer-present');
|
||||
|
||||
expect(page.items, isEmpty);
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
test('search sends Plex universal-search values and deduplicates media', () async {
|
||||
late http.Request captured;
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
captured = request;
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'SearchResults': [
|
||||
{
|
||||
'SearchResult': [
|
||||
{'Metadata': _metadata()},
|
||||
{'Metadata': _metadata()},
|
||||
{
|
||||
'Metadata': {'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
final results = await source.search(' Inception ', limit: 12);
|
||||
|
||||
expect(captured.url.path, '/library/search');
|
||||
expect(captured.url.queryParameters, containsPair('query', 'Inception'));
|
||||
expect(captured.url.queryParameters, containsPair('limit', '12'));
|
||||
expect(captured.url.queryParameters, containsPair('searchTypes', 'movies,tv'));
|
||||
expect(captured.url.queryParameters, containsPair('searchProviders', 'discover'));
|
||||
expect(results, hasLength(1));
|
||||
expect(results.single.ids.plex, 'plex-movie-1');
|
||||
});
|
||||
|
||||
test('watchlist snapshot and mutation use the advertised action endpoint', () async {
|
||||
var watchlisted = true;
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.path == '/library/sections/watchlist/all') {
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'totalSize': watchlisted ? 1 : 0,
|
||||
'Metadata': watchlisted ? [_metadata()] : <Object>[],
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(request.method, 'PUT');
|
||||
expect(request.url.path, '/actions/removeFromWatchlist');
|
||||
expect(request.url.queryParameters['ratingKey'], 'plex-movie-1');
|
||||
watchlisted = false;
|
||||
return _json(const <String, Object?>{});
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
const ids = CatalogItemIds(plex: 'plex-movie-1', imdb: 'tt1375666');
|
||||
|
||||
await source.ensureWatchlistLoaded();
|
||||
expect(source.isOnWatchlist(MediaKind.movie, ids), isTrue);
|
||||
|
||||
await source.removeFromWatchlist(MediaKind.movie, ids);
|
||||
expect(source.isOnWatchlist(MediaKind.movie, ids), isFalse);
|
||||
expect(requests, hasLength(2));
|
||||
});
|
||||
|
||||
test('watchlist mutation resolves a missing Plex rating key from external ids', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.path == '/library/metadata/matches') {
|
||||
expect(request.url.queryParameters['guid'], 'imdb://tt1375666');
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Metadata': [_metadata()],
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(request.method, 'PUT');
|
||||
expect(request.url.path, '/actions/addToWatchlist');
|
||||
expect(request.url.queryParameters['ratingKey'], 'plex-movie-1');
|
||||
return _json(const <String, Object?>{});
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
await source.addToWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt1375666'));
|
||||
|
||||
expect(requests.map((request) => request.url.path), ['/library/metadata/matches', '/actions/addToWatchlist']);
|
||||
});
|
||||
test('external-id matching enables cast and related detail flows', () async {
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
switch (request.url.path) {
|
||||
case '/library/metadata/matches':
|
||||
expect(request.url.queryParameters['guid'], 'imdb://tt1375666');
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Metadata': [_metadata(type: 'show')],
|
||||
},
|
||||
});
|
||||
case '/library/metadata/plex-movie-1':
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
..._metadata(type: 'show'),
|
||||
'Role': [
|
||||
{'tag': 'Ken Watanabe', 'role': 'Saito', 'thumb': 'https://images.plex.tv/ken.jpg'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
case '/library/metadata/plex-movie-1/related':
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
{
|
||||
'Metadata': [_metadata(ratingKey: 'related-1', title: 'Interstellar')],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return _json({'error': 'unexpected'}, 500);
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
final resolved = await source.resolveItemIds(MediaKind.show, const ExternalIds(imdb: 'tt1375666'));
|
||||
expect(resolved?.plex, 'plex-movie-1');
|
||||
expect(resolved?.imdb, 'tt1375666');
|
||||
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.plex,
|
||||
kind: MediaKind.show,
|
||||
title: 'Inception',
|
||||
ids: CatalogItemIds(plex: 'plex-movie-1'),
|
||||
);
|
||||
final cast = await source.fetchCast(item);
|
||||
final related = await source.fetchRelated(item);
|
||||
|
||||
expect(cast.single.name, 'Ken Watanabe');
|
||||
expect(cast.single.secondary, 'Saito');
|
||||
expect(related.single.title, 'Interstellar');
|
||||
});
|
||||
|
||||
test('Discover requests have a bounded duration', () async {
|
||||
final response = Completer<http.Response>();
|
||||
final client = PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) => response.future),
|
||||
requestTimeout: Duration.zero,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
await expectLater(client.getWatchlist(), throwsA(isA<TimeoutException>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import 'dart:async';
|
||||
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/models/simkl/simkl_trending_item.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/simkl_catalog_source.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_client.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
|
||||
TrackerSession _session() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return TrackerSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: null,
|
||||
expiresAt: now + 86400,
|
||||
scope: null,
|
||||
createdAt: now - 3600,
|
||||
username: 'alice',
|
||||
);
|
||||
}
|
||||
|
||||
http.Response _json(Object? body, {int status = 200, Map<String, String>? headers}) =>
|
||||
http.Response(json.encode(body), status, headers: {'content-type': 'application/json', ...?headers});
|
||||
|
||||
Map<String, dynamic> _trending({required int simkl, String title = 'Inception', String? animeType}) => {
|
||||
'title': title,
|
||||
'poster': '12/posterhash',
|
||||
'fanart': '34/fanarthash',
|
||||
'release_date': '07/16/2010',
|
||||
'runtime': '2h 37m',
|
||||
'status': 'ended',
|
||||
'overview': 'Dreams within dreams.',
|
||||
'genres': ['Action', 'Science Fiction'],
|
||||
'trailer': 'YoHD9XEInc0',
|
||||
'total_episodes': animeType == null ? null : 12,
|
||||
'anime_type': ?animeType,
|
||||
'ids': {'simkl_id': simkl, 'slug': 'inception', 'imdb': 'tt1375666', 'tmdb': '27205', 'tvdb': '123'},
|
||||
'ratings': {
|
||||
'simkl': {'rating': 8.8, 'votes': 1234},
|
||||
},
|
||||
};
|
||||
|
||||
Map<String, dynamic> _best(int simkl) => {
|
||||
'title': 'Best $simkl',
|
||||
'year': 2020,
|
||||
'ids': {'simkl': simkl},
|
||||
};
|
||||
|
||||
Map<String, dynamic> _allItemsBody() => {
|
||||
'movies': [
|
||||
{
|
||||
'status': 'plantowatch',
|
||||
'movie': {
|
||||
'title': 'Inception',
|
||||
'year': 2010,
|
||||
'poster': '12/posterhash',
|
||||
'runtime': 148,
|
||||
'ids': {'simkl': 1, 'imdb': 'tt1375666', 'tmdb': '27205'},
|
||||
},
|
||||
},
|
||||
],
|
||||
'shows': [
|
||||
{
|
||||
'status': 'plantowatch',
|
||||
'total_episodes_count': 62,
|
||||
'show': {
|
||||
'title': 'Breaking Bad',
|
||||
'year': 2008,
|
||||
'poster': '97/showhash',
|
||||
'ids': {'simkl': 2, 'tvdb': '81189'},
|
||||
},
|
||||
},
|
||||
],
|
||||
'anime': [
|
||||
{
|
||||
'status': 'plantowatch',
|
||||
'anime_type': 'movie',
|
||||
'show': {
|
||||
'title': 'Your Name.',
|
||||
'year': 2016,
|
||||
'poster': '55/animehash',
|
||||
'ids': {'simkl': 3, 'mal': '32281', 'anilist': '21519'},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('Simkl models', () {
|
||||
test('parses runtime strings and rejects malformed values', () {
|
||||
expect(SimklTrendingItem.fromJson(_trending(simkl: 1)).runtimeMinutes, 157);
|
||||
expect(SimklTrendingItem.fromJson({..._trending(simkl: 1), 'runtime': '45m'}).runtimeMinutes, 45);
|
||||
expect(SimklTrendingItem.fromJson({..._trending(simkl: 1), 'runtime': 'unknown'}).runtimeMinutes, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('SimklCatalogSource', () {
|
||||
late List<http.Request> requests;
|
||||
late FutureOr<http.Response> Function(http.Request request) responder;
|
||||
late SimklClient client;
|
||||
late SimklCatalogSource source;
|
||||
late int invalidations;
|
||||
|
||||
setUp(() {
|
||||
requests = [];
|
||||
invalidations = 0;
|
||||
responder = (request) => _json([]);
|
||||
client = SimklClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => invalidations++,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
return responder(request);
|
||||
}),
|
||||
);
|
||||
source = SimklCatalogSource(client);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
source.dispose();
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('trending uses CDN, coerces ids, builds images, and serves page two from cache', () async {
|
||||
responder = (request) => _json([_trending(simkl: 1), _trending(simkl: 2, title: 'Second')]);
|
||||
|
||||
final first = await source.fetchRow(CatalogRowId.trendingMovies, limit: 1);
|
||||
final second = await source.fetchRow(CatalogRowId.trendingMovies, page: 2, limit: 1);
|
||||
|
||||
expect(requests, hasLength(1));
|
||||
final request = requests.single;
|
||||
expect(request.url.host, 'data.simkl.in');
|
||||
expect(request.url.path, '/discover/trending/movies/week_100.json');
|
||||
expect(request.url.queryParameters['client_id'], SimklConstants.clientId);
|
||||
expect(request.url.queryParameters['app-name'], SimklConstants.appName);
|
||||
expect(request.url.queryParameters['app-version'], SimklConstants.appVersion);
|
||||
expect(request.headers, isNot(contains('authorization')));
|
||||
expect(request.headers['user-agent'], '${SimklConstants.appName}/${SimklConstants.appVersion}');
|
||||
|
||||
expect(first.hasMore, isTrue);
|
||||
expect(first.items.single.ids.simkl, 1);
|
||||
expect(first.items.single.ids.tmdb, 27205);
|
||||
expect(first.items.single.year, 2010);
|
||||
expect(first.items.single.runtimeMinutes, 157);
|
||||
expect(first.items.single.posterUrl, 'https://simkl.in/posters/12/posterhash_m.webp');
|
||||
expect(first.items.single.backdropUrl, 'https://simkl.in/fanart/34/fanarthash_medium.webp');
|
||||
expect(second.items.single.title, 'Second');
|
||||
expect(second.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('CDN 401 does not invalidate the authenticated Simkl session', () async {
|
||||
responder = (request) => http.Response('denied', 401);
|
||||
|
||||
await expectLater(source.fetchRow(CatalogRowId.trendingMovies), throwsA(isA<TrackerApiException>()));
|
||||
expect(invalidations, 0);
|
||||
});
|
||||
|
||||
test('anime_type movie maps a trending anime to MediaKind.movie', () async {
|
||||
responder = (request) => _json([_trending(simkl: 3, animeType: 'movie')]);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.trendingAnime);
|
||||
expect(page.items.single.kind, MediaKind.movie);
|
||||
expect(page.items.single.episodeCount, isNull);
|
||||
});
|
||||
|
||||
test('best TV uses watched endpoint and tolerates a literal null body', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.path, '/tv/best/watched');
|
||||
return http.Response('null', 200, headers: {'content-type': 'application/json'});
|
||||
};
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.popularShows);
|
||||
expect(page.items, isEmpty);
|
||||
expect(page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('best rows expose all cached pages without refetching', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.path, '/tv/best/watched');
|
||||
return _json([for (var i = 1; i <= 60; i++) _best(i)]);
|
||||
};
|
||||
|
||||
final first = await source.fetchRow(CatalogRowId.popularShows, limit: 25);
|
||||
final second = await source.fetchRow(CatalogRowId.popularShows, page: 2, limit: 25);
|
||||
final third = await source.fetchRow(CatalogRowId.popularShows, page: 3, limit: 25);
|
||||
|
||||
expect(requests, hasLength(1));
|
||||
expect(first.items, hasLength(25));
|
||||
expect(first.hasMore, isTrue);
|
||||
expect(second.items, hasLength(25));
|
||||
expect(second.hasMore, isTrue);
|
||||
expect(third.items, hasLength(10));
|
||||
expect(third.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('watchlist row maps full entries and reuses its warm cache', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.path, '/sync/all-items/all/plantowatch');
|
||||
expect(request.url.queryParameters['extended'], 'full');
|
||||
expect(request.headers['authorization'], 'Bearer access');
|
||||
return _json(_allItemsBody());
|
||||
};
|
||||
|
||||
final first = await source.fetchRow(CatalogRowId.watchlist, limit: 10);
|
||||
final second = await source.fetchRow(CatalogRowId.watchlist, limit: 10);
|
||||
expect(requests, hasLength(1));
|
||||
expect(first.items, hasLength(3));
|
||||
final movie = first.items.first;
|
||||
expect(movie.kind, MediaKind.movie);
|
||||
expect(movie.ids.simkl, 1);
|
||||
expect(movie.ids.tmdb, 27205);
|
||||
expect(movie.runtimeMinutes, 148);
|
||||
expect(first.items.last.kind, MediaKind.movie);
|
||||
expect(first.items.last.ids.anilist, 21519);
|
||||
expect(second.items, hasLength(3));
|
||||
});
|
||||
|
||||
test('watchlist row and membership snapshot share one full-library download', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.queryParameters['extended'], 'full');
|
||||
return _json(_allItemsBody());
|
||||
};
|
||||
|
||||
await source.fetchRow(CatalogRowId.watchlist);
|
||||
await source.ensureWatchlistLoaded();
|
||||
|
||||
expect(requests, hasLength(1));
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 27205)), isTrue);
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tmdb: 27205)), isFalse);
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tvdb: 81189)), isTrue);
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 3)), isTrue);
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(simkl: 3)), isTrue);
|
||||
});
|
||||
|
||||
test('add uses per-item plantowatch and remove uses history/remove with bare ids', () async {
|
||||
final bodies = <Map<String, dynamic>>[];
|
||||
responder = (request) {
|
||||
bodies.add(json.decode(request.body) as Map<String, dynamic>);
|
||||
return _json({
|
||||
'added': <String, dynamic>{},
|
||||
'not_found': <String, dynamic>{},
|
||||
}, status: request.url.path == '/sync/add-to-list' ? 201 : 200);
|
||||
};
|
||||
const ids = CatalogItemIds(simkl: 1, slug: 'response-only', imdb: 'tt1375666', tmdb: 27205);
|
||||
|
||||
await source.addToWatchlist(MediaKind.movie, ids);
|
||||
await source.removeFromWatchlist(MediaKind.movie, ids);
|
||||
|
||||
expect(requests.map((request) => request.url.path), ['/sync/add-to-list', '/sync/history/remove']);
|
||||
final added = (bodies.first['movies'] as List).single as Map<String, dynamic>;
|
||||
expect(added['to'], 'plantowatch');
|
||||
expect(added['ids'], {'simkl': 1, 'imdb': 'tt1375666', 'tmdb': 27205});
|
||||
final removed = (bodies.last['movies'] as List).single as Map<String, dynamic>;
|
||||
expect(removed.keys, ['ids']);
|
||||
});
|
||||
|
||||
test('successful mutations invalidate the shared watchlist payload', () async {
|
||||
var allItemsRequests = 0;
|
||||
responder = (request) {
|
||||
if (request.url.path == '/sync/all-items/all/plantowatch') {
|
||||
allItemsRequests++;
|
||||
return _json(_allItemsBody());
|
||||
}
|
||||
return _json(const <String, Object?>{}, status: 201);
|
||||
};
|
||||
|
||||
await source.fetchRow(CatalogRowId.watchlist);
|
||||
await source.addToWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 4));
|
||||
await source.fetchRow(CatalogRowId.watchlist);
|
||||
|
||||
expect(allItemsRequests, 2);
|
||||
});
|
||||
|
||||
test('failed mutation restores optimistic membership', () async {
|
||||
var loadingSnapshot = true;
|
||||
responder = (request) {
|
||||
if (loadingSnapshot) return _json(_allItemsBody());
|
||||
return http.Response('failed', 500);
|
||||
};
|
||||
await source.ensureWatchlistLoaded();
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 1)), isTrue);
|
||||
|
||||
loadingSnapshot = false;
|
||||
await expectLater(
|
||||
source.removeFromWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 1)),
|
||||
throwsA(isA<TrackerApiException>()),
|
||||
);
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 1)), isTrue);
|
||||
});
|
||||
|
||||
test('snapshot errors are swallowed and membership remains unknown', () async {
|
||||
responder = (request) => http.Response('failed', 500);
|
||||
|
||||
await source.ensureWatchlistLoaded();
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 1)), isNull);
|
||||
});
|
||||
|
||||
test('search fans out across movie, TV, and anime and merges mapped results', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.queryParameters['q'], 'cowboy');
|
||||
expect(request.url.queryParameters['extended'], 'full');
|
||||
final (endpoint, type, animeType) = switch (request.url.path) {
|
||||
'/search/movie' => ('movies', 'Movie', null),
|
||||
'/search/tv' => ('tv', 'Show', null),
|
||||
'/search/anime' => ('anime', 'Anime Movie', 'movie'),
|
||||
_ => throw StateError('unexpected path ${request.url.path}'),
|
||||
};
|
||||
return _json(
|
||||
[
|
||||
{
|
||||
'title': type,
|
||||
'year': 2020,
|
||||
'endpoint_type': endpoint,
|
||||
'type': ?animeType,
|
||||
'poster': '1/hash',
|
||||
'ids': {'simkl_id': endpoint.hashCode.abs()},
|
||||
'ratings': {
|
||||
'simkl': {'rating': 8.0, 'votes': 10},
|
||||
},
|
||||
},
|
||||
],
|
||||
headers: {'x-pagination-page': '1', 'x-pagination-page-count': '1', 'x-pagination-item-count': '1'},
|
||||
);
|
||||
};
|
||||
|
||||
final results = await source.search(' cowboy ', limit: 30);
|
||||
|
||||
expect(requests.map((request) => request.url.path).toSet(), {'/search/movie', '/search/tv', '/search/anime'});
|
||||
expect(results.map((item) => item.title), ['Movie', 'Show', 'Anime Movie']);
|
||||
expect(results.map((item) => item.kind), [MediaKind.movie, MediaKind.show, MediaKind.movie]);
|
||||
});
|
||||
|
||||
test('fetchCast performs no requests', () async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Inception',
|
||||
ids: CatalogItemIds(simkl: 1),
|
||||
);
|
||||
|
||||
expect(await source.fetchCast(item), isEmpty);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('related retries anime when the kind endpoint returns an empty array', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.queryParameters, isNot(contains('extended')));
|
||||
if (request.url.path == '/tv/3') return _json([]);
|
||||
expect(request.url.path, '/anime/3');
|
||||
return _json({
|
||||
'users_recommendations': [
|
||||
{
|
||||
'title': 'A Silent Voice',
|
||||
'year': 2016,
|
||||
'poster': '1/related',
|
||||
'type': 'anime',
|
||||
'ids': {'simkl': 4, 'mal': 28851},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.show,
|
||||
title: 'Anime',
|
||||
ids: CatalogItemIds(simkl: 3),
|
||||
);
|
||||
|
||||
final related = await source.fetchRelated(item);
|
||||
|
||||
expect(requests.map((request) => request.url.path), ['/tv/3', '/anime/3']);
|
||||
expect(related.single.title, 'A Silent Voice');
|
||||
expect(related.single.ids.mal, 28851);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user