Files
plezy/test/services/catalog/plex_catalog_source_test.dart
T
edde746 2cb2c3eb95 feat(ratings): show every rating source the server already sent
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor
took `rating` and fell back to `audienceRating` only when it was null, so a
Plex movie carrying four attributed scores surfaced one, and which one was
whatever the server happened to put in the scalar slot. #1755 asked for a
setting to choose the source; showing all of them answers it without one.

The data was already on the wire and being thrown away. `/library/metadata/
{id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels,
TMDB — with no extra query parameter, but PlexMetadataDto declared no field
for it, so json_serializable dropped the key. The identical parse already
existed in plex_catalog_source for the Explore tab and had simply never been
wired to library items.

Model the scores as a list rather than widening the scalar pair. The neutral
MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage
and audienceRatingImage, which the list subsumes — Plex sends those images
on listings too, so the same field covers both response shapes and no caller
narrows to a backend type to read a score any more. CatalogRatingSource is
promoted to lib/media as MediaRatingSource instead of growing a second
near-identical type beside it, and plex_catalog_source's _ratingsFor becomes
the shared plexRatingSources so one implementation serves both paths. There
is no persistence to migrate: MediaItem.toJson has no production caller, the
offline path re-parses raw Plex JSON through the same mapper, and Plex's
audienceRating sort is server-supplied data, not a model read.

Cards and the dashboard still show fewer scores than detail screens, and
that part is a real Plex limit rather than a shortcut. Section listings send
only the scalar pair; includeRatings, includeElements=Rating,
includeFields=Rating, includeChildren and includeExtras were each probed
against a live server and none surfaced the array, while includeGuids=1
demonstrably does add Guid[] — the probe works, the parameter does not
exist. Hydrating every card would be one request per row, so listings render
whatever their own response carried, which is one or two attributed scores
rather than the single one they showed before.

Jellyfin has no per-source array at all: the server collapses whatever its
fetchers found into CommunityRating and CriticRating. CommunityRating's
provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or
a local NFO, last writer wins — so it stays the generic `audience` source
with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a
0-100 percent and is divided by ten explicitly rather than folded by
magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have
promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin
reuses CommunityRating for the EXIF 0-5 star.

The badges share one slot on every surface. On the phone hero the scores go
in a single pill because that chip row is a height-clipped Wrap and a chip
per source would push year, certification and runtime out of the visible
band on short heroes; on the TV detail line and the dashboard spotlight the
group occupies the one metadata slot so bullet separators do not multiply.
The group announces itself as a single semantics node naming each source,
because a bare row of four percentages tells a screen reader nothing about
which score is which. rating_utils drops parseRatingImage and
isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper —
and the source-key resolver and label map, previously private to the Explore
detail screen, become the shared pair both screens use. The label strings
move from explore.ratingSource to common.ratingSource accordingly, which
costs no translations because every non-English value was empty; running
clean_translations also scaffolds startup.quitPlezy and
startup.restartRequiredBody, which were already drifted.

Verified against the live server the probes came from: a detail response now
yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the
production mapper and badge resolver, and the listing response for the same
title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures.
Coverage adds mapper ordering, dedupe against the array's repeat of the
scalar, out-of-range rejection, the Jellyfin scale and photo guard, the
CatalogItem conversion that feeds Explore's dashboard hubs, and the three
render surfaces including the semantics announcement.

close #1755
2026-08-02 03:59:56 +02:00

847 lines
32 KiB
Dart

import 'dart:async';
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/media/media_rating.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.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';
import '../../test_helpers/http_fixtures.dart';
const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id');
Map<String, Object?> _metadata({
String ratingKey = 'plex-movie-1',
String type = 'movie',
String title = 'Inception',
String imdb = 'tt1375666',
int tmdb = 27205,
}) => {
'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://$imdb'},
{'id': 'tmdb://$tmdb'},
],
};
/// Discover answers `/hubs/sections/<section>` with placeholders only — the
/// shelf identity, never its items.
Map<String, Object?> _placeholderHub(String id, String title, {String type = 'mixed', String? style}) => {
'hubIdentifier': id,
'key': '/hubs/sections/home/${id.split('.').last}?source=home',
'title': title,
'type': type,
'style': ?style,
'placeholder': true,
'size': 0,
'more': true,
};
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 jsonResponse({
'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['includeGuids'], '1');
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);
expect(page.totalResults, 27);
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('maps every attributed score and leaves absent optional metadata null', () async {
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient(
(_) async => jsonResponse({
'MediaContainer': {
'totalSize': 2,
'Metadata': [
{
..._metadata(),
'rating': 9.4,
'ratingImage': 'rottentomatoes://image.rating.ripe',
'audienceRating': 9.7,
'audienceRatingImage': 'rottentomatoes://image.rating.upright',
'imdbRatingCount': 250858,
'Rating': [
{'image': 'imdb://image.rating', 'type': 'audience', 'value': 8.5},
{'image': 'rottentomatoes://image.rating.ripe', 'type': 'critic', 'value': 94},
{'image': 'rottentomatoes://image.rating.upright', 'type': 'audience', 'value': 9.7},
{'image': 'themoviedb://image.rating', 'type': 'audience', 'value': 8},
],
'originallyAvailableAt': '2010-07-16',
'originalTitle': 'Origine',
'tagline': 'Your mind is the scene of the crime.',
'banner': 'https://metadata-static.plex.tv/banner.jpg',
'budget': 160000000,
'revenue': '839000000',
},
{
// Distinct external ids: the mapper dedupes on identity,
// and reusing the default imdb/tmdb would collapse this
// row into the scored one above.
..._metadata(ratingKey: 'plex-movie-2', title: 'No Score', imdb: 'tt0000002', tmdb: 2),
'rating': null,
},
],
},
}),
),
),
);
addTearDown(source.dispose);
final page = await source.fetchRow(CatalogRowId.watchlist);
final item = page.items.first;
final ratings = {for (final rating in item.ratings!) rating.source: (value: rating.value, votes: rating.votes)};
expect(item.rating, 9.4);
expect(ratings, {
'rottenTomatoesCritic': (value: 9.4, votes: null),
'rottenTomatoesAudience': (value: 9.7, votes: null),
'imdb': (value: 8.5, votes: 250858),
'tmdb': (value: 8.0, votes: null),
});
expect(item.releaseDate, DateTime(2010, 7, 16));
expect(item.originalTitle, 'Origine');
expect(item.tagline, 'Your mind is the scene of the crime.');
expect(item.bannerUrl, 'https://metadata-static.plex.tv/banner.jpg');
expect(item.budget, 160000000);
expect(item.revenue, 839000000);
final absent = page.items.last;
expect(absent.rating, isNull);
expect(absent.ratings, isNull);
expect(absent.releaseDate, isNull);
expect(absent.playState, isNull);
expect(absent.posterVariants, isNull);
expect(absent.backdropVariants, isNull);
});
test('home shelves are hydrated from their placeholder keys', () async {
final requests = <http.Request>[];
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requests.add(request);
switch (request.url.path) {
case '/hubs/sections/home':
// Discover answers the section listing with placeholders: no
// hub carries Metadata, so each shelf needs its own request.
return jsonResponse({
'MediaContainer': {
'Hub': [
_placeholderHub('home.trending-plex', 'Trending on Plex', style: 'shelf'),
_placeholderHub('home.genres', 'Browse by Genre', type: 'directory'),
_placeholderHub('home.new-trailers', 'New Trailers', type: 'clip'),
_placeholderHub('home.people', 'People'),
_placeholderHub('home.platforms', 'Available On', style: 'availabilityPlatforms'),
_placeholderHub('home.chris-nolan', 'The Films of Sir Christopher Nolan'),
],
},
});
case '/hubs/sections/home/trending-plex':
return jsonResponse({
'MediaContainer': {
'Metadata': [
_metadata(),
{
..._metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
'isContinuingSeries': true,
'nextEpisodeOriginallyAvailableAt': '2026-08-04',
},
_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar'),
],
},
});
case '/hubs/sections/home/people':
return jsonResponse({
'MediaContainer': {
'Metadata': [
{'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'},
],
},
});
case '/hubs/sections/home/platforms':
return jsonResponse({
'MediaContainer': {
'Metadata': [
{
..._metadata(ratingKey: 'platform-1', title: 'A Platform Title'),
'viewCount': 2,
'viewOffset': 12345,
'viewedLeafCount': 7,
},
],
},
});
case '/hubs/sections/home/chris-nolan':
return jsonResponse({
'MediaContainer': {
'Metadata': [_metadata(ratingKey: 'plex-movie-3', title: 'The Prestige')],
},
});
}
return jsonResponse({'error': 'unexpected'}, status: 500);
}),
),
);
addTearDown(source.dispose);
final hubs = await source.fetchHubs(limit: 2);
// Browse-category and trailer shelves cannot produce a catalog item, so
// they never cost a hydration request.
expect(requests.map((request) => request.url.path), [
'/hubs/sections/home',
'/hubs/sections/home/trending-plex',
'/hubs/sections/home/people',
'/hubs/sections/home/platforms',
'/hubs/sections/home/chris-nolan',
]);
final shelfRequest = requests.singleWhere((request) => request.url.path == '/hubs/sections/home/trending-plex');
final platformRequest = requests.singleWhere((request) => request.url.path == '/hubs/sections/home/platforms');
expect(shelfRequest.url.queryParameters, containsPair('limit', '3'));
expect(shelfRequest.url.queryParameters, containsPair('includeMeta', '1'));
expect(shelfRequest.url.queryParameters, containsPair('includeUserState', '1'));
expect(shelfRequest.url.queryParameters, containsPair('source', 'home'));
expect(shelfRequest.url.queryParameters, containsPair('excludeElements', 'Media,Image'));
expect(platformRequest.url.queryParameters, containsPair('excludeElements', 'Media,Image'));
// The people-only shelf maps to nothing and drops out. Explicit styles
// survive, while an absent hint remains null instead of becoming shelf.
expect(hubs.map((hub) => hub.id), ['home.trending-plex', 'home.platforms', 'home.chris-nolan']);
expect(hubs.map((hub) => hub.style), [CatalogHubStyle.shelf, CatalogHubStyle.availabilityPlatforms, null]);
expect(hubs.first.title, 'Trending on Plex');
expect(hubs.first.page.items.map((item) => item.title), ['Inception', 'Severance']);
expect(hubs.first.page.hasMore, isTrue);
final show = hubs.first.page.items.last;
expect(show.airStatus, CatalogAirStatus.airing);
expect(show.nextEpisode?.airsAt, DateTime(2026, 8, 4));
expect(show.endDate, isNull);
final platformItem = hubs[1].page.items.single;
expect(platformItem.playState?.viewCount, 2);
expect(platformItem.playState?.viewOffsetMs, 12345);
expect(platformItem.playState?.viewedLeafCount, 7);
expect(hubs.last.page.items.single.title, 'The Prestige');
expect(hubs.last.page.hasMore, isFalse);
});
test('source forwards the explicit hub Image opt-in', () async {
final requests = <http.Request>[];
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requests.add(request);
if (request.url.path == '/hubs/sections/home') {
return jsonResponse({
'MediaContainer': {
'Hub': [_placeholderHub('home.spotlight', 'Spotlight')],
},
});
}
return jsonResponse({
'MediaContainer': {
'Metadata': [_metadata()],
},
});
}),
),
includeImageVariants: true,
);
addTearDown(source.dispose);
await source.fetchHubs();
expect(requests.last.url.queryParameters, containsPair('includeUserState', '1'));
expect(requests.last.url.queryParameters, containsPair('excludeElements', 'Media'));
});
test('View All takes a shelf in one request because Discover ignores offsets', () async {
final requests = <http.Request>[];
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requests.add(request);
if (request.url.path == '/hubs/sections/home') {
return jsonResponse({
'MediaContainer': {
'Hub': [_placeholderHub('home.trending-plex', 'Trending on Plex')],
},
});
}
return jsonResponse({
'MediaContainer': {
'Metadata': [
_metadata(),
_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar', imdb: 'tt0816692', tmdb: 157336),
],
},
});
}),
),
);
addTearDown(source.dispose);
await source.fetchHubs(limit: 1);
requests.clear();
final page = await source.fetchHub('home.trending-plex', limit: 100);
expect(requests.single.url.queryParameters, containsPair('limit', '100'));
expect(requests.single.url.queryParameters.containsKey('X-Plex-Container-Start'), isFalse);
expect(page.items.map((item) => item.title), ['Inception', 'Interstellar']);
expect(page.hasMore, isFalse);
// A second page would replay the same items, so it is never requested.
requests.clear();
final beyond = await source.fetchHub('home.trending-plex', page: 2, limit: 100);
expect(beyond.items, isEmpty);
expect(requests, isEmpty);
});
test('one failing shelf degrades, an entirely failing listing surfaces the error', () async {
var failEverything = false;
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
if (request.url.path == '/hubs/sections/home') {
return jsonResponse({
'MediaContainer': {
'Hub': [
_placeholderHub('home.trending-plex', 'Trending on Plex'),
_placeholderHub('home.retired', 'Retired'),
],
},
});
}
if (failEverything || request.url.path == '/hubs/sections/home/retired') {
return jsonResponse({'error': 'gone'}, status: 500);
}
return jsonResponse({
'MediaContainer': {
'Metadata': [_metadata()],
},
});
}),
),
);
addTearDown(source.dispose);
final hubs = await source.fetchHubs(limit: 25);
expect(hubs.map((hub) => hub.id), ['home.trending-plex']);
failEverything = true;
await expectLater(source.fetchHubs(limit: 25), throwsA(isA<PlexDiscoverException>()));
});
test('a vanished home shelf degrades to an empty page', () async {
final requests = <http.Request>[];
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requests.add(request);
return jsonResponse({'error': 'unexpected'}, status: 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 jsonResponse({
'MediaContainer': {
'SearchResults': [
{
'SearchResult': [
{'score': 0.91, 'Metadata': _metadata()},
{'score': 0.42, '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');
expect(results.single.relevance, 0.91);
});
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 jsonResponse({
'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 jsonResponse(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));
// The snapshot page must stay under Discover's container-size cap
// (#1715: 500 was rejected outright).
expect(requests.first.url.queryParameters['X-Plex-Container-Size'], '100');
});
test('an oversized watchlist page refetches as chunks when Discover rejects it', () async {
final requests = <http.Request>[];
final client = PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requests.add(request);
final size = int.parse(request.url.queryParameters['X-Plex-Container-Size']!);
if (size > 25) {
return jsonResponse({
'Error': {'message': 'Invalid value provided for x-plex-container-size!'},
}, status: 400);
}
final start = int.parse(request.url.queryParameters['X-Plex-Container-Start']!);
const total = 180;
final count = (total - start).clamp(0, size);
return jsonResponse({
'MediaContainer': {
'totalSize': total,
'Metadata': [
for (var i = 0; i < count; i++)
{'ratingKey': 'rk-${start + i}', 'type': 'movie', 'title': 'Movie ${start + i}'},
],
},
});
}),
);
addTearDown(client.dispose);
final page = await client.getWatchlist(page: 2, limit: 100);
// The rejected request, then the same range in proven-size chunks:
// the caller's offset math survives the cap drift.
expect(
requests.map(
(request) => (
request.url.queryParameters['X-Plex-Container-Start'],
request.url.queryParameters['X-Plex-Container-Size'],
),
),
[('100', '100'), ('100', '25'), ('125', '25'), ('150', '25'), ('175', '25')],
);
expect(page.items, hasLength(80));
expect(page.items.first['ratingKey'], 'rk-100');
expect(page.items.last['ratingKey'], 'rk-179');
expect(page.hasMore, isFalse);
expect(page.totalResults, 180);
});
test('other Discover rejections surface instead of chunking', () async {
var requestCount = 0;
final client = PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requestCount++;
return jsonResponse({
'Error': {'message': 'Maintenance'},
}, status: 400);
}),
);
addTearDown(client.dispose);
await expectLater(client.getWatchlist(limit: 100), throwsA(isA<PlexDiscoverException>()));
expect(requestCount, 1);
});
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 jsonResponse({
'MediaContainer': {
'Metadata': [_metadata()],
},
});
}
expect(request.method, 'PUT');
expect(request.url.path, '/actions/addToWatchlist');
expect(request.url.queryParameters['ratingKey'], 'plex-movie-1');
return jsonResponse(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 and fetchDetail return enriched item, cast, and related', () async {
final requests = <http.Request>[];
final metadataResponse = Completer<http.Response>();
var relatedRequested = false;
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) {
requests.add(request);
switch (request.url.path) {
case '/library/metadata/matches':
expect(request.url.queryParameters['guid'], 'imdb://tt1375666');
return Future.value(
jsonResponse({
'MediaContainer': {
'Metadata': [_metadata(type: 'show')],
},
}),
);
case '/library/metadata/plex-movie-1':
return metadataResponse.future;
case '/library/metadata/plex-movie-1/related':
relatedRequested = true;
return Future.value(
jsonResponse({
'MediaContainer': {
'Hub': [
{
'Metadata': [_metadata(ratingKey: 'related-1', title: 'Interstellar')],
},
],
},
}),
);
}
return Future.value(jsonResponse({'error': 'unexpected'}, status: 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',
overview: 'Row overview.',
ids: CatalogItemIds(plex: 'plex-movie-1'),
relevance: 0.73,
);
final detailFuture = source.fetchDetail(item);
await Future<void>.delayed(Duration.zero);
expect(relatedRequested, isTrue, reason: 'metadata and related requests must start concurrently');
metadataResponse.complete(
jsonResponse({
'MediaContainer': {
'Metadata': [
{
..._metadata(type: 'show'),
'summary': 'Short summary.',
'Summary': [
{'type': 'default', 'tag': 'A complete and much longer summary from detail metadata.'},
],
'Role': [
{'tag': 'Ken Watanabe', 'role': 'Saito', 'thumb': 'https://images.plex.tv/ken.jpg'},
],
'Director': [
{'tag': 'Christopher Nolan'},
],
'Writer': [
{'tag': 'Jonathan Nolan'},
],
'Producer': [
{'tag': 'Emma Thomas'},
],
'Country': [
{'tag': 'United Kingdom'},
{'tag': 'United States of America'},
],
'Studio': [
{'tag': 'Warner Bros.'},
],
'Genre': [
{'tag': 'Science Fiction'},
{'tag': 'Thriller'},
],
'Rating': [
{'image': 'imdb://image.rating', 'type': 'audience', 'value': 8.5},
],
'imdbRatingCount': 250858,
'CommonSenseMedia': [
{
'oneLiner': 'Complex themes and sustained peril.',
'AgeRating': [
{'age': 15, 'rating': 5, 'type': 'official'},
],
},
],
'originallyAvailableAt': '2010-07-16',
'originalTitle': 'Origine',
'tagline': 'Your mind is the scene of the crime.',
'isContinuingSeries': false,
'lastEpisodeOriginallyAvailableAt': '2010-12-01',
'thumb': null,
'art': null,
'banner': null,
'Image': [
{
'type': 'clearLogoWide',
'alt': 'Inception',
'url': 'https://metadata-static.plex.tv/inception-logo.png',
},
{
'type': 'coverPoster',
'alt': 'Inception',
'url': 'https://metadata-static.plex.tv/inception-poster.jpg',
},
{
'type': 'background',
'alt': 'Inception',
'url': 'https://metadata-static.plex.tv/inception-background.jpg',
},
{'type': 'banner', 'alt': 'Inception', 'url': 'https://assets.fanart.tv/inception-banner.jpg'},
],
},
],
},
}),
);
final detail = await detailFuture;
expect(detail.item.overview, 'A complete and much longer summary from detail metadata.');
expect(detail.item.relevance, 0.73);
expect(detail.item.genres, ['Science Fiction', 'Thriller']);
expect(detail.item.studios, ['Warner Bros.']);
expect(detail.item.countries, ['GB', 'US']);
expect(
{for (final credit in detail.item.credits!) credit.role: credit.name},
{
CatalogCreditRole.director: 'Christopher Nolan',
CatalogCreditRole.writer: 'Jonathan Nolan',
CatalogCreditRole.producer: 'Emma Thomas',
},
);
expect(detail.item.contentAdvisory, '15+ · Complex themes and sustained peril.');
expect(detail.item.releaseDate, DateTime(2010, 7, 16));
expect(detail.item.endDate, DateTime(2010, 12, 1));
expect(detail.item.airStatus, CatalogAirStatus.ended);
expect(detail.item.originalTitle, 'Origine');
expect(detail.item.tagline, 'Your mind is the scene of the crime.');
expect(detail.item.logoUrl, 'https://metadata-static.plex.tv/inception-logo.png');
expect(detail.item.posterUrl, 'https://metadata-static.plex.tv/inception-poster.jpg');
expect(detail.item.backdropUrl, 'https://metadata-static.plex.tv/inception-background.jpg');
expect(detail.item.bannerUrl, 'https://assets.fanart.tv/inception-banner.jpg');
expect(detail.item.posterVariants, isNull);
expect(detail.item.backdropVariants, isNull);
expect(
detail.item.ratings,
contains(
isA<MediaRatingSource>()
.having((rating) => rating.source, 'source', 'imdb')
.having((rating) => rating.value, 'value', 8.5)
.having((rating) => rating.votes, 'votes', 250858),
),
);
expect(detail.cast.single.name, 'Ken Watanabe');
expect(detail.cast.single.secondary, 'Saito');
expect(detail.related.single.title, 'Interstellar');
expect(requests.where((request) => request.url.path == '/library/metadata/plex-movie-1'), hasLength(1));
expect(requests.where((request) => request.url.path == '/library/metadata/plex-movie-1/related'), hasLength(1));
});
test('fetchDetail keeps enrichment and cast when the related call fails', () async {
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/related')) {
return jsonResponse({'error': 'related unavailable'}, status: 503);
}
return jsonResponse({
'MediaContainer': {
'Metadata': [
{
..._metadata(),
'summary': 'Detailed overview.',
'Director': [
{'tag': 'Christopher Nolan'},
],
'Role': [
{'tag': 'Ken Watanabe', 'role': 'Saito'},
],
},
],
},
});
}),
),
);
addTearDown(source.dispose);
const item = CatalogItem(
source: CatalogSourceId.plex,
kind: MediaKind.movie,
title: 'Inception',
ids: CatalogItemIds(plex: 'plex-movie-1'),
);
final detail = await source.fetchDetail(item);
expect(detail.item.overview, 'Detailed overview.');
expect(detail.item.credits?.single.name, 'Christopher Nolan');
expect(detail.cast.single.name, 'Ken Watanabe');
expect(detail.related, isEmpty);
});
test('fetchDetail without a Plex rating key returns the row without requests', () async {
var requested = false;
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((_) async {
requested = true;
return jsonResponse({'error': 'unexpected'}, status: 500);
}),
),
);
addTearDown(source.dispose);
const item = CatalogItem(
source: CatalogSourceId.plex,
kind: MediaKind.movie,
title: 'Inception',
ids: CatalogItemIds(imdb: 'tt1375666'),
);
final detail = await source.fetchDetail(item);
expect(identical(detail.item, item), isTrue);
expect(detail.cast, isEmpty);
expect(detail.related, isEmpty);
expect(requested, isFalse);
});
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>()));
});
});
}