feat(explore): surface the catalog data providers already return

Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.

Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.

Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.

Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.

Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
This commit is contained in:
edde746
2026-07-29 06:47:54 +02:00
parent 0ef71e6489
commit 27acbaf435
97 changed files with 12945 additions and 880 deletions
+203 -14
View File
@@ -4,7 +4,9 @@ import '../../media/media_kind.dart';
import '../../models/anilist/anilist_media.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/trackers/fribb_mapping_row.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../trackers/anilist/anilist_client.dart';
import '../trackers/fribb_mapping_store.dart';
@@ -17,6 +19,8 @@ import 'catalog_watchlist_machinery.dart';
/// The client is owned and rebound by `TrackersProvider`; this source only
/// borrows it. Fribb bridges AniList/MAL identities to media-server IDs.
class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
static final RegExp _accentColorPattern = RegExp(r'^#[0-9a-fA-F]{6}$');
final AnilistClient _client;
final FribbMappingLookup _fribb;
final FutureCoalescer<int> _viewerIdLoad = FutureCoalescer<int>();
@@ -118,7 +122,10 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
source: CatalogSourceId.anilist,
kind: anime.isMovie ? MediaKind.movie : MediaKind.show,
title: anime.displayTitle,
altTitles: [
// A set literal: insertion-ordered and deduped. Providers repeat titles
// across fields — AniList lists the English title again under `synonyms` —
// and a duplicate here costs the reverse library lookup a wasted request.
altTitles: <String>{
for (final title in <String?>[
anime.titleEnglish,
anime.titleUserPreferred,
@@ -127,7 +134,7 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
...?anime.synonyms,
])
if (title != null && title.isNotEmpty && title != anime.displayTitle) title,
],
}.toList(),
year: anime.year,
overview: anime.description,
runtimeMinutes: anime.runtimeMinutes,
@@ -150,27 +157,209 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
: ExternalSeasonRef(tvdb: row.tvdbSeason, tmdb: row.tmdbSeason),
posterUrl: anime.posterUrl,
backdropUrl: anime.backdropUrl,
accentColor: _accentColorFor(anime.coverImageColor),
ratings: _ratingsFor(anime.meanRating),
ranks: _ranksFor(anime.rankings),
audience: _audienceFor(anime),
nextEpisode: _nextEpisodeFor(anime.nextAiringEpisode),
releaseDate: anime.releaseDate,
endDate: anime.finalEpisodeDate,
originalTitle: _differentTitle(anime.titleNative, anime.displayTitle),
broadcastSeason: _seasonFor(anime.season, anime.seasonYear),
format: _formatFor(anime.format),
sourceMaterial: _sourceMaterialFor(anime.source),
studios: anime.mainStudios,
countries: _countriesFor(anime.countryOfOrigin),
credits: _creditsFor(anime.staffCredits),
tags: _tagsFor(anime.tags),
links: _linksFor(anime.externalLinks, anime.streamingEpisodes),
cast: _castFor(anime.characters),
);
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final anilistId = item.ids.anilist;
if (anilistId == null) return const [];
final characters = await _client.getAnimeCharacters(anilistId, limit: limit);
return [
for (final character in characters)
if (character.name.isNotEmpty)
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
if (anilistId == null) return CatalogDetail(item: item);
final cachedCast = item.cast;
final response = await _client.getAnimeDetail(
anilistId,
castLimit: castLimit,
relatedLimit: relatedLimit,
includeCharacters: cachedCast == null,
);
final relationEdges = [
for (final edge in response.relations)
if (edge.item.id != null) edge,
];
final mapped = await Future.wait([
_toCatalogItems(response.recommendations),
_toCatalogItems([for (final edge in relationEdges) edge.item]),
]);
final groupedRelations = <CatalogRelationType, List<CatalogItem>>{};
for (var i = 0; i < relationEdges.length; i++) {
final type = _relationTypeFor(relationEdges[i].relationType);
groupedRelations.putIfAbsent(type, () => []).add(mapped[1][i]);
}
final detailItem = response.item;
final mappedDetail = detailItem == null ? null : _toCatalogItem(detailItem, null);
return CatalogDetail(
item: mappedDetail == null ? item : item.enrichedWith(mappedDetail),
cast: cachedCast ?? mappedDetail?.cast ?? const [],
related: mapped[0],
relations: [for (final entry in groupedRelations.entries) CatalogRelation(type: entry.key, items: entry.value)],
);
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
final anilistId = item.ids.anilist;
if (anilistId == null) return const [];
return _toCatalogItems(await _client.getAnimeRecommendations(anilistId, limit: limit));
static List<CatalogCastMember>? _castFor(List<AnilistCharacter>? characters) => characters == null
? null
: [
for (final character in characters)
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
];
static List<CatalogRatingSource>? _ratingsFor(double? meanRating) =>
meanRating == null ? null : [CatalogRatingSource(source: 'anilist', value: meanRating)];
static CatalogNextEpisode? _nextEpisodeFor(AnilistNextAiringEpisode? airing) => airing == null
? null
: CatalogNextEpisode(
episode: airing.episode,
airsAt: DateTime.fromMillisecondsSinceEpoch(airing.airingAt * Duration.millisecondsPerSecond, isUtc: true),
);
static CatalogSeasonInfo? _seasonFor(String? season, int? year) {
final name = CatalogSeasonInfo.parseName(season);
return name == null ? null : CatalogSeasonInfo(name: name, year: year);
}
static List<CatalogRank>? _ranksFor(List<AnilistRanking>? rankings) {
if (rankings == null) return null;
final ranks = [
for (final ranking in rankings)
if (_rankScopeFor(ranking.type) case final CatalogRankScope scope)
CatalogRank(
rank: ranking.rank,
scope: scope,
allTime: ranking.allTime,
year: ranking.year,
season: CatalogSeasonInfo.parseName(ranking.season),
),
];
return ranks.isEmpty ? null : ranks;
}
static CatalogRankScope? _rankScopeFor(String? type) => switch (type) {
'POPULAR' => CatalogRankScope.popular,
'RATED' => CatalogRankScope.rated,
_ => null,
};
static CatalogAudience? _audienceFor(AnilistMedia anime) {
if (anime.popularity == null && anime.favourites == null && anime.trending == null) return null;
return CatalogAudience(listed: anime.popularity, favorited: anime.favourites, trendingActivity: anime.trending);
}
static String? _accentColorFor(String? value) {
final color = value?.trim();
return color != null && _accentColorPattern.hasMatch(color) ? color.toLowerCase() : null;
}
static String? _differentTitle(String? value, String displayTitle) {
final title = value?.trim();
if (title == null || title.isEmpty || title.toLowerCase() == displayTitle.toLowerCase()) return null;
return title;
}
static CatalogFormat? _formatFor(String? format) => switch (format) {
null => null,
'TV' => CatalogFormat.tv,
'TV_SHORT' => CatalogFormat.tvShort,
'MOVIE' => CatalogFormat.movie,
'SPECIAL' => CatalogFormat.special,
'OVA' => CatalogFormat.ova,
'ONA' => CatalogFormat.ona,
'MUSIC' => CatalogFormat.music,
_ => CatalogFormat.other,
};
static CatalogSourceMaterial? _sourceMaterialFor(String? source) => switch (source) {
null => null,
'ORIGINAL' => CatalogSourceMaterial.original,
'MANGA' || 'COMIC' || 'DOUJINSHI' => CatalogSourceMaterial.manga,
'LIGHT_NOVEL' => CatalogSourceMaterial.lightNovel,
'NOVEL' || 'WEB_NOVEL' => CatalogSourceMaterial.novel,
'VISUAL_NOVEL' => CatalogSourceMaterial.visualNovel,
'VIDEO_GAME' || 'GAME' => CatalogSourceMaterial.game,
'WEB_COMIC' => CatalogSourceMaterial.webComic,
'MUSIC' => CatalogSourceMaterial.musicRelease,
_ => CatalogSourceMaterial.otherMedia,
};
static List<String>? _countriesFor(String? country) {
final code = country == null ? null : CountryCodes.normalizeCode(country);
return code == null || code.isEmpty ? null : [code];
}
static List<CatalogCredit>? _creditsFor(List<AnilistStaffCredit>? staff) {
if (staff == null) return null;
final credits = <CatalogCredit>[];
for (final member in staff) {
final role = _creditRoleFor(member.role);
if (role == null) continue;
final credit = CatalogCredit(name: member.name, role: role);
if (!credits.any((existing) => existing.name == credit.name && existing.role == credit.role)) {
credits.add(credit);
}
}
return credits.isEmpty ? null : credits;
}
static CatalogCreditRole? _creditRoleFor(String raw) {
final role = raw.trim().toLowerCase();
if (role == 'director' || role == 'series director') return CatalogCreditRole.director;
if (role == 'writer' || role == 'series composition' || role.contains('script') || role.contains('screenplay')) {
return CatalogCreditRole.writer;
}
if (role == 'music' || role.contains('composer') || role.contains('music composition')) {
return CatalogCreditRole.composer;
}
return null;
}
static List<CatalogTag>? _tagsFor(List<AnilistTag>? tags) => tags == null
? null
: [for (final tag in tags) CatalogTag(name: tag.name, rank: tag.rank, isSpoiler: tag.isMediaSpoiler)];
static List<CatalogLink>? _linksFor(List<AnilistLink>? external, List<AnilistLink>? streaming) {
final links = <CatalogLink>[];
for (final link in external ?? const <AnilistLink>[]) {
links.add(CatalogLink(label: link.label, url: link.url));
}
for (final link in streaming ?? const <AnilistLink>[]) {
final catalogLink = CatalogLink(label: link.label, url: link.url, isStreaming: true);
final existing = links.indexWhere((candidate) => candidate.url == link.url);
if (existing == -1) {
links.add(catalogLink);
} else {
links[existing] = catalogLink;
}
}
return links.isEmpty ? null : links;
}
static CatalogRelationType _relationTypeFor(String? type) => switch (type) {
'PREQUEL' => CatalogRelationType.prequel,
'SEQUEL' => CatalogRelationType.sequel,
'SIDE_STORY' => CatalogRelationType.sideStory,
'SPIN_OFF' => CatalogRelationType.spinOff,
'ALTERNATIVE' => CatalogRelationType.alternativeVersion,
'SUMMARY' => CatalogRelationType.summary,
'PARENT' => CatalogRelationType.parentStory,
'ADAPTATION' => CatalogRelationType.adaptation,
_ => CatalogRelationType.other,
};
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async {
if (!external.hasAny) return null;
+84 -11
View File
@@ -48,7 +48,28 @@ class CatalogPage {
final List<CatalogItem> items;
final bool hasMore;
const CatalogPage({required this.items, this.hasMore = false});
/// Provider-reported size of the whole result set, when the envelope says
/// (Seerr `totalResults`). Null when the provider only reports `hasMore`.
final int? totalResults;
const CatalogPage({required this.items, this.hasMore = false, this.totalResults});
}
/// How a provider intends a hub to be presented. Providers tag hubs (Plex
/// `style`), and rendering every one as the same shelf discards that.
///
/// Only values a provider was actually observed to send are declared. A live
/// Plex Home response returned 17 renderable hubs: 5 `shelf`, 1
/// `availabilityPlatforms`, 11 with no style at all. Absent stays absent —
/// null means "the provider expressed no preference", which is not the same
/// as `shelf`, even though both render identically today. Do not add a
/// speculative style before a provider is seen sending it.
enum CatalogHubStyle {
/// The default horizontal poster shelf.
shelf,
/// A hub whose entries are streaming services/platforms rather than titles.
availabilityPlatforms,
}
/// One provider-defined Explore shelf. Unlike [CatalogRowId], hubs are
@@ -58,7 +79,64 @@ class CatalogHub {
final String title;
final CatalogPage page;
const CatalogHub({required this.id, required this.title, required this.page});
/// The provider's presentation hint, or null when it sent none.
final CatalogHubStyle? style;
const CatalogHub({required this.id, required this.title, required this.page, this.style});
}
/// Everything a catalog detail screen needs from the item's own provider.
///
/// The contract is a request-count ceiling, not a single response: an
/// implementation must issue no more requests than the separate cast and
/// related calls it replaces, and [item] must be enriched purely from bodies
/// it was already fetching. Providers differ — Simkl's detail body already
/// carries recommendations, while Plex (`/library/metadata/{id}` plus
/// `/related`) and Seerr (detail plus `/recommendations`) genuinely need two.
/// Where two calls remain, run them concurrently and isolate their failures:
/// a failed related call must still yield the enriched item and its cast.
class CatalogDetail {
/// The opening item enriched with everything the detail body added, via
/// [CatalogItem.enrichedWith]. Equal to the input item when the provider
/// has no detail endpoint.
final CatalogItem item;
/// Actors with characters, or characters with roles, in billing order.
final List<CatalogCastMember> cast;
/// "More like this" — recommendations, which are a similarity judgement and
/// carry no relationship to the item.
final List<CatalogItem> related;
/// Franchise relations, grouped and labelled by how each group relates to
/// the item (MAL `related_anime`, AniList `relations`). Deliberately not
/// merged into [related]: "sequel" is a fact about the work, "recommended"
/// is an opinion about taste, and flattening them would lose the label the
/// UI needs to head each shelf.
final List<CatalogRelation> relations;
const CatalogDetail({required this.item, this.cast = const [], this.related = const [], this.relations = const []});
}
/// How a group of titles relates to the item they were fetched from.
enum CatalogRelationType {
prequel,
sequel,
sideStory,
spinOff,
alternativeVersion,
summary,
parentStory,
adaptation,
other,
}
/// One labelled group of franchise relations.
class CatalogRelation {
final CatalogRelationType type;
final List<CatalogItem> items;
const CatalogRelation({required this.type, required this.items});
}
/// Optional capability for catalog providers that expose dynamic hub rows.
@@ -95,15 +173,10 @@ abstract class CatalogSource {
/// rejects queries under 3 characters).
Future<List<CatalogItem>> search(String query, {int limit = 30});
/// Cast of an item for its detail screen (actors with characters, or MAL
/// characters with roles), in billing order. One request, fetched lazily
/// on detail open; empty when the provider has none for this item.
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20});
/// "More like this" titles for an item's detail screen (Trakt related,
/// MAL recommendations, Seerr/TMDB recommendations). One request, fetched
/// lazily on detail open; empty when the provider has none.
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20});
/// Enriched item, cast and related titles for a detail screen, in as few
/// requests as the provider allows. Fetched lazily on detail open; a
/// provider with nothing to add returns the item unchanged and empty lists.
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20});
/// Load the full watchlist membership snapshot (coalesced; cached for the
/// session). [isOnWatchlist] returns null until this has completed once.
+273 -55
View File
@@ -3,8 +3,10 @@ import 'package:collection/collection.dart';
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/mal/mal_anime.dart';
import '../../models/trackers/fribb_mapping_row.dart';
import '../../utils/app_logger.dart';
import '../../utils/external_ids.dart';
import '../trackers/fribb_mapping_store.dart';
import '../trackers/mal/mal_client.dart';
@@ -74,7 +76,15 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
CatalogRowId.upcomingMovies ||
CatalogRowId.upcomingShows => throw ArgumentError('MAL does not serve ${row.name}'),
};
return CatalogPage(items: await _toCatalogItems(res.items), hasMore: res.hasMore);
final rankingScope = switch (row) {
CatalogRowId.airingAnime => CatalogRankScope.airing,
CatalogRowId.popularAnime => CatalogRankScope.popular,
_ => null,
};
return CatalogPage(
items: await _toCatalogItems(res.items, rankingRanks: res.rankingRanks, rankingScope: rankingScope),
hasMore: res.hasMore,
);
}
/// MAL rejects queries under 3 characters (`invalid q`) — return empty
@@ -88,21 +98,83 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final malId = item.ids.mal;
if (malId == null) return const [];
return _toCatalogItems(await _client.getAnimeRecommendations(malId, limit: limit));
if (malId == null) return CatalogDetail(item: item);
final responses = await Future.wait<Object?>([_loadAnimeDetail(malId, relatedLimit), _loadCast(malId, castLimit)]);
final detail = responses[0] as MalAnimeDetail?;
final cast = responses[1] as List<CatalogCastMember>;
if (detail == null) return CatalogDetail(item: item, cast: cast);
final recommendations = [
for (final recommendation in detail.recommendations)
if (recommendation.anime.id != null) recommendation,
];
final relationEntries = [
for (final relation in detail.relations)
if (relation.anime.id != null) relation,
];
final mapped = await Future.wait([
_toCatalogItems(
[for (final recommendation in recommendations) recommendation.anime],
recommendationCounts: [for (final recommendation in recommendations) recommendation.count],
),
_toCatalogItems([for (final relation in relationEntries) relation.anime]),
]);
final related = mapped[0];
final relationItems = mapped[1];
final groupedRelations = <CatalogRelationType, List<CatalogItem>>{};
for (var i = 0; i < relationEntries.length; i++) {
groupedRelations
.putIfAbsent(_relationTypeFor(relationEntries[i].type), () => <CatalogItem>[])
.add(relationItems[i]);
}
final detailPosterUrl = detail.anime.mainPicture?.primary ?? item.posterUrl;
final detailItem = _toCatalogItem(
detail.anime,
null,
statistics: detail.statistics,
gallery: _galleryFor(detail.pictures, detailPosterUrl),
background: detail.background,
);
return CatalogDetail(
item: item.enrichedWith(detailItem),
cast: cast,
related: related,
relations: [for (final entry in groupedRelations.entries) CatalogRelation(type: entry.key, items: entry.value)],
);
}
/// Enrich concurrently so all items share one Fribb index load (per-item
/// awaits would retry the download for every item when it is failing).
Future<List<CatalogItem>> _toCatalogItems(List<MalAnime> anime) async {
final withIds = [
for (final entry in anime)
if (entry.id != null) entry,
];
Future<List<CatalogItem>> _toCatalogItems(
List<MalAnime> anime, {
List<int?> rankingRanks = const [],
CatalogRankScope? rankingScope,
List<int?> recommendationCounts = const [],
}) async {
final withIds = <MalAnime>[];
final originalIndexes = <int>[];
for (var i = 0; i < anime.length; i++) {
if (anime[i].id == null) continue;
withIds.add(anime[i]);
originalIndexes.add(i);
}
final rows = await Future.wait([for (final entry in withIds) _fribb.lookupByMal(entry.id!)]);
return [for (var i = 0; i < withIds.length; i++) _toCatalogItem(withIds[i], rows[i])];
return [
for (var i = 0; i < withIds.length; i++)
_toCatalogItem(
withIds[i],
rows[i],
rankingRank: originalIndexes[i] < rankingRanks.length ? rankingRanks[originalIndexes[i]] : null,
rankingScope: rankingScope,
recommendationCount: originalIndexes[i] < recommendationCounts.length
? recommendationCounts[originalIndexes[i]]
: null,
),
];
}
/// Normalize MAL's status strings. `finished_airing` on a movie maps to
@@ -114,53 +186,199 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
_ => null,
};
CatalogItem _toCatalogItem(MalAnime anime, FribbMappingRow? row) => CatalogItem(
source: CatalogSourceId.mal,
kind: anime.isMovie ? MediaKind.movie : MediaKind.show,
title: anime.displayTitle,
altTitles: [
for (final title in <String?>[
anime.alternativeTitles?.en,
anime.title,
anime.alternativeTitles?.ja,
...?anime.alternativeTitles?.synonyms,
])
if (title != null && title.isNotEmpty && title != anime.displayTitle) title,
],
year: anime.year,
overview: anime.synopsis,
runtimeMinutes: anime.runtimeMinutes,
rating: anime.mean,
votes: anime.numScoringUsers,
genres: anime.genreNames,
certification: anime.certification,
airStatus: airStatusFor(anime),
episodeCount: anime.isMovie || (anime.numEpisodes ?? 0) <= 0 ? null : anime.numEpisodes,
network: anime.primaryStudio,
ids: CatalogItemIds(
mal: anime.id,
imdb: row?.imdbIds?.firstOrNull,
tmdb: row?.tmdbIds?.firstOrNull,
tvdb: row?.tvdbId,
),
season: row == null || (row.tvdbSeason == null && row.tmdbSeason == null)
? null
: ExternalSeasonRef(tvdb: row.tvdbSeason, tmdb: row.tmdbSeason),
posterUrl: anime.mainPicture?.primary,
);
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
final malId = item.ids.mal;
if (malId == null) return const [];
final res = await _client.getAnimeCharacters(malId, limit: limit);
return [
for (final character in res.items)
if (character.name.isNotEmpty)
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
];
CatalogItem _toCatalogItem(
MalAnime anime,
FribbMappingRow? row, {
int? rankingRank,
CatalogRankScope? rankingScope,
int? recommendationCount,
MalStatistics? statistics,
List<String>? gallery,
String? background,
}) {
final title = anime.displayTitle;
final originalTitle = anime.title;
return CatalogItem(
source: CatalogSourceId.mal,
kind: anime.isMovie ? MediaKind.movie : MediaKind.show,
title: title,
// Set literal: insertion-ordered and deduped. MAL repeats the English
// and romaji titles inside `synonyms`, and a duplicate candidate costs
// the reverse library lookup a wasted request.
altTitles: <String>{
for (final candidate in <String?>[
anime.alternativeTitles?.en,
anime.title,
anime.alternativeTitles?.ja,
...?anime.alternativeTitles?.synonyms,
])
if (candidate != null && candidate.isNotEmpty && candidate != title) candidate,
}.toList(),
year: anime.year,
overview: anime.synopsis,
runtimeMinutes: anime.runtimeMinutes,
rating: anime.mean,
votes: anime.numScoringUsers,
genres: anime.genreNames,
certification: anime.certification,
airStatus: airStatusFor(anime),
episodeCount: anime.isMovie || (anime.numEpisodes ?? 0) <= 0 ? null : anime.numEpisodes,
network: anime.primaryStudio,
ids: CatalogItemIds(
mal: anime.id,
anilist: row?.anilistId,
simkl: row?.simklId,
imdb: row?.imdbIds?.firstOrNull,
tmdb: row?.tmdbIds?.firstOrNull,
tvdb: row?.tvdbId,
),
// Which season of the parent series this entry maps to, for the reverse
// library lookup — distinct from the calendar broadcastSeason below.
season: row == null || (row.tvdbSeason == null && row.tmdbSeason == null)
? null
: ExternalSeasonRef(tvdb: row.tvdbSeason, tmdb: row.tmdbSeason),
posterUrl: anime.mainPicture?.primary,
ranks: _ranksFor(anime, rankingRank: rankingRank, rankingScope: rankingScope),
audience: _audienceFor(anime, statistics),
broadcast: _broadcastFor(anime.broadcast),
endDate: _dateFor(anime.endDate),
originalTitle: originalTitle != null && originalTitle.isNotEmpty && originalTitle != title ? originalTitle : null,
broadcastSeason: _seasonFor(anime.startSeason),
sourceMaterial: _sourceMaterialFor(anime.source),
isAdult: _isAdultFor(anime.nsfw),
recommendationCount: recommendationCount,
gallery: gallery,
background: background,
);
}
Future<MalAnimeDetail?> _loadAnimeDetail(int malId, int relatedLimit) async {
try {
return await _client.getAnimeDetail(malId, relatedLimit: relatedLimit);
} catch (e, st) {
appLogger.w('MAL: anime detail load failed', error: e, stackTrace: st);
return null;
}
}
Future<List<CatalogCastMember>> _loadCast(int malId, int limit) async {
try {
final res = await _client.getAnimeCharacters(malId, limit: limit);
return [
for (final character in res.items)
if (character.name.isNotEmpty)
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
];
} catch (e, st) {
appLogger.w('MAL: character load failed', error: e, stackTrace: st);
return const [];
}
}
static List<CatalogRank>? _ranksFor(MalAnime anime, {int? rankingRank, CatalogRankScope? rankingScope}) {
final ranks = <CatalogRank>[];
var rowRankIsPopular = false;
if (rankingRank != null && rankingRank > 0 && rankingScope != null) {
ranks.add(CatalogRank(rank: rankingRank, scope: rankingScope, allTime: true));
rowRankIsPopular = rankingScope == CatalogRankScope.popular;
}
final popularity = anime.popularity;
if (popularity != null && popularity > 0 && !rowRankIsPopular) {
ranks.add(CatalogRank(rank: popularity, scope: CatalogRankScope.popular, allTime: true));
}
final scoreRank = anime.rank;
if (scoreRank != null && scoreRank > 0) {
ranks.add(CatalogRank(rank: scoreRank, scope: CatalogRankScope.rated, allTime: true));
}
return ranks.isEmpty ? null : ranks;
}
static CatalogAudience? _audienceFor(MalAnime anime, MalStatistics? statistics) {
final audience = CatalogAudience(
listed: statistics?.numListUsers ?? anime.numListUsers,
planning: statistics?.planToWatch,
watching: statistics?.watching,
completed: statistics?.completed,
onHold: statistics?.onHold,
dropped: statistics?.dropped,
);
return audience.isEmpty ? null : audience;
}
static CatalogBroadcast? _broadcastFor(MalBroadcast? broadcast) {
if (broadcast == null) return null;
final weekday = switch (broadcast.dayOfTheWeek?.toLowerCase()) {
'monday' => DateTime.monday,
'tuesday' => DateTime.tuesday,
'wednesday' => DateTime.wednesday,
'thursday' => DateTime.thursday,
'friday' => DateTime.friday,
'saturday' => DateTime.saturday,
'sunday' => DateTime.sunday,
_ => null,
};
final time = _normalizedTime(broadcast.startTime);
if (weekday == null && time == null) return null;
return CatalogBroadcast(weekday: weekday, time: time, timezone: 'Asia/Tokyo');
}
static String? _normalizedTime(String? value) {
if (value == null || value.length != 5 || value[2] != ':') return null;
final hour = int.tryParse(value.substring(0, 2));
final minute = int.tryParse(value.substring(3));
if (hour == null || hour < 0 || hour > 23 || minute == null || minute < 0 || minute > 59) {
return null;
}
return value;
}
static CatalogSeasonInfo? _seasonFor(MalStartSeason? season) {
final name = CatalogSeasonInfo.parseName(season?.season);
return name == null ? null : CatalogSeasonInfo(name: name, year: season?.year);
}
static CatalogSourceMaterial? _sourceMaterialFor(String? source) => switch (source) {
null || '' => null,
'original' => CatalogSourceMaterial.original,
'manga' || '4_koma_manga' || 'digital_manga' => CatalogSourceMaterial.manga,
'light_novel' => CatalogSourceMaterial.lightNovel,
'novel' => CatalogSourceMaterial.novel,
'visual_novel' => CatalogSourceMaterial.visualNovel,
'game' || 'card_game' => CatalogSourceMaterial.game,
'web_manga' => CatalogSourceMaterial.webComic,
'music' => CatalogSourceMaterial.musicRelease,
_ => CatalogSourceMaterial.otherMedia,
};
static bool? _isAdultFor(String? nsfw) => switch (nsfw) {
'gray' || 'black' => true,
'white' => false,
_ => null,
};
static DateTime? _dateFor(String? value) => value == null ? null : DateTime.tryParse(value);
static List<String>? _galleryFor(List<MalPicture> pictures, String? posterUrl) {
final urls = <String>{};
for (final picture in pictures) {
final url = picture.primary;
if (url != null && url != posterUrl) urls.add(url);
}
return urls.isEmpty ? null : urls.toList(growable: false);
}
static CatalogRelationType _relationTypeFor(String? type) => switch (type) {
'prequel' => CatalogRelationType.prequel,
'sequel' => CatalogRelationType.sequel,
'side_story' => CatalogRelationType.sideStory,
'spin_off' => CatalogRelationType.spinOff,
'alternative_version' => CatalogRelationType.alternativeVersion,
'summary' => CatalogRelationType.summary,
'parent_story' => CatalogRelationType.parentStory,
'adaptation' => CatalogRelationType.adaptation,
_ => CatalogRelationType.other,
};
/// Reverse-map a library item's external ids to its MAL entry. A show-level
/// id can resolve to several rows (split-cour anime, one row per season);
/// prefer season 1 — "add this show" means its first season on MAL. Null
+257 -35
View File
@@ -1,7 +1,10 @@
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../utils/external_ids.dart';
import '../../utils/app_logger.dart';
import '../../utils/country_codes.dart';
import '../../utils/json_utils.dart';
import '../plex_discover_client.dart';
import 'catalog_source.dart';
@@ -12,9 +15,10 @@ import 'catalog_watchlist_machinery.dart';
/// Home ▸ Trending tab).
class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource, CatalogHubSource {
final PlexDiscoverClient _client;
final bool includeImageVariants;
final Map<String, String> _hubKeys = {};
PlexCatalogSource(this._client);
PlexCatalogSource(this._client, {this.includeImageVariants = false});
@override
CatalogSourceId get id => CatalogSourceId.plex;
@@ -41,23 +45,29 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
if (row != CatalogRowId.watchlist) throw ArgumentError('Plex does not serve ${row.name}');
final response = await _client.getWatchlist(page: page, limit: limit);
return CatalogPage(items: _fromMetadata(response.items), hasMore: response.hasMore);
return CatalogPage(
items: _fromMetadata(response.items),
hasMore: response.hasMore,
totalResults: response.totalResults,
);
}
@override
Future<List<CatalogHub>> fetchHubs({int limit = 25}) async {
final fetched = await _client.getHomeHubs(limit: limit);
final fetched = await _client.getHomeHubs(limit: limit, includeImageVariants: includeImageVariants);
final keys = <String, String>{};
final result = <CatalogHub>[];
for (final hub in fetched) {
final items = _fromMetadata(hub.page.items);
if (items.isEmpty) continue;
final style = _hubStyleFor(hub.style);
keys[hub.id] = hub.key;
result.add(
CatalogHub(
id: hub.id,
title: hub.title,
page: CatalogPage(items: items, hasMore: hub.page.hasMore),
style: style,
page: CatalogPage(items: items, hasMore: hub.page.hasMore, totalResults: hub.page.totalResults),
),
);
}
@@ -73,15 +83,15 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
Future<CatalogPage> fetchHub(String id, {int page = 1, int limit = 25}) async {
final key = _hubKeys[id];
if (key == null || page > 1) return const CatalogPage(items: []);
final response = await _client.getHub(key, limit: limit);
return CatalogPage(items: _fromMetadata(response.items), hasMore: false);
final response = await _client.getHub(key, limit: limit, includeImageVariants: includeImageVariants);
return CatalogPage(items: _fromMetadata(response.items), hasMore: false, totalResults: response.totalResults);
}
@override
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
return _fromMetadata(await _client.search(trimmed, limit: limit));
return _fromSearchResults(await _client.search(trimmed, limit: limit));
}
@override
@@ -111,27 +121,51 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
}
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final ratingKey = item.ids.plex;
if (ratingKey == null) return const [];
final metadata = await _client.getMetadata(ratingKey);
if (metadata == null) return const [];
return [
for (final role in flexibleMapList(metadata['Role']).take(limit))
if (_nonEmptyString(role['tag'] ?? role['name']) case final String name)
CatalogCastMember(
name: name,
secondary: _nonEmptyString(role['role']),
imageUrl: _nonEmptyString(role['thumb']),
),
];
if (ratingKey == null || ratingKey.isEmpty) return CatalogDetail(item: item);
final metadataFuture = _loadDetailMetadata(ratingKey);
final relatedFuture = _loadRelatedMetadata(ratingKey);
final metadata = await metadataFuture;
final relatedMetadata = await relatedFuture;
final detailItem = metadata == null ? null : _toCatalogItem(metadata);
final safeCastLimit = castLimit < 0 ? 0 : castLimit;
final safeRelatedLimit = relatedLimit < 0 ? 0 : relatedLimit;
return CatalogDetail(
item: detailItem == null ? item : item.enrichedWith(detailItem),
cast: metadata == null
? const []
: [
for (final role in flexibleMapList(metadata['Role']).take(safeCastLimit))
if (_nonEmptyString(role['tag'] ?? role['name']) case final String name)
CatalogCastMember(
name: name,
secondary: _nonEmptyString(role['role']),
imageUrl: _nonEmptyString(role['thumb']),
),
],
related: _fromMetadata(relatedMetadata).take(safeRelatedLimit).toList(),
);
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
final ratingKey = item.ids.plex;
if (ratingKey == null) return const [];
return _fromMetadata(await _client.getRelated(ratingKey)).take(limit).toList();
Future<Map<String, dynamic>?> _loadDetailMetadata(String ratingKey) async {
try {
return await _client.getMetadata(ratingKey);
} catch (error, stackTrace) {
appLogger.w('Plex: detail metadata failed', error: error, stackTrace: stackTrace);
return null;
}
}
Future<List<Map<String, dynamic>>> _loadRelatedMetadata(String ratingKey) async {
try {
return await _client.getRelated(ratingKey);
} catch (error, stackTrace) {
appLogger.w('Plex: related metadata failed', error: error, stackTrace: stackTrace);
return const [];
}
}
@override
@@ -162,41 +196,229 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
return items;
}
CatalogItem? _toCatalogItem(Map<String, dynamic> metadata) {
List<CatalogItem> _fromSearchResults(List<PlexDiscoverSearchResult> results) {
final items = <CatalogItem>[];
final seen = <String>{};
for (final result in results) {
final item = _toCatalogItem(result.metadata, relevance: result.score);
if (item != null && seen.add(item.identityKey)) items.add(item);
}
return items;
}
CatalogItem? _toCatalogItem(Map<String, dynamic> metadata, {double? relevance}) {
final kind = _kindFor(metadata['type']);
final title = _nonEmptyString(metadata['title']);
final ids = _idsFor(metadata);
if (kind == null || title == null || ids.plex == null) return null;
final genres = [
for (final genre in flexibleMapList(metadata['Genre']))
if (_nonEmptyString(genre['tag']) case final String name) name,
];
final genres = _tagsFor(metadata['Genre']);
final studios = _tagsFor(metadata['Studio']);
final countries = _countriesFor(metadata['Country']);
final credits = _creditsFor(metadata);
final ratings = _ratingsFor(metadata);
final durationMs = flexibleInt(metadata['duration']);
final continuing = flexibleBoolNullable(metadata['isContinuingSeries']);
final nextAirDate = kind == MediaKind.show
? _date(metadata['nextEpisodeOriginallyAvailableAt']) ?? _date(metadata['nextSeasonOriginallyAvailableAt'])
: null;
final playState = CatalogPlayState(
viewCount: flexibleInt(metadata['viewCount']),
viewOffsetMs: flexibleInt(metadata['viewOffset']),
viewedLeafCount: flexibleInt(metadata['viewedLeafCount']),
);
String? coverPoster;
String? coverArt;
String? background;
String? clearLogo;
String? clearLogoWide;
String? imageBanner;
for (final image in flexibleMapList(metadata['Image'])) {
final type = _nonEmptyString(image['type']);
final url = _nonEmptyString(image['url']);
if (type == null || url == null) continue;
switch (type) {
case 'coverPoster':
coverPoster ??= url;
case 'coverArt':
coverArt ??= url;
case 'background':
background ??= url;
case 'clearLogo':
clearLogo ??= url;
case 'clearLogoWide':
clearLogoWide ??= url;
case 'banner':
imageBanner ??= url;
}
}
final headlineRating = _normalizedRating(metadata['rating']);
final audienceRating = _normalizedRating(metadata['audienceRating']);
return CatalogItem(
source: CatalogSourceId.plex,
kind: kind,
title: title,
year: flexibleInt(metadata['year']),
overview: _nonEmptyString(metadata['summary']),
overview: _overviewFor(metadata),
runtimeMinutes: durationMs == null ? null : Duration(milliseconds: durationMs).inMinutes,
rating: flexibleDouble(metadata['rating'] ?? metadata['audienceRating']),
genres: genres.isEmpty ? null : genres,
rating: headlineRating ?? audienceRating,
genres: genres,
certification: _nonEmptyString(metadata['contentRating']),
airStatus: kind == MediaKind.show && continuing != null
? continuing
? CatalogAirStatus.airing
: CatalogAirStatus.ended
: null,
episodeCount: kind == MediaKind.show ? flexibleInt(metadata['leafCount']) : null,
network: _nonEmptyString(metadata['studio'] ?? metadata['network']),
ids: ids,
posterUrl: _nonEmptyString(metadata['thumb']),
backdropUrl: _nonEmptyString(metadata['art']),
posterUrl: _nonEmptyString(metadata['thumb']) ?? coverPoster ?? coverArt,
backdropUrl: _nonEmptyString(metadata['art']) ?? background,
logoUrl: clearLogoWide ?? clearLogo,
bannerUrl: _nonEmptyString(metadata['banner']) ?? imageBanner,
ratings: ratings,
nextEpisode: nextAirDate == null ? null : CatalogNextEpisode(airsAt: nextAirDate),
releaseDate: _date(metadata['originallyAvailableAt']),
endDate: kind == MediaKind.show && continuing == false
? _date(metadata['lastEpisodeOriginallyAvailableAt']) ?? _date(metadata['lastSeasonOriginallyAvailableAt'])
: null,
originalTitle: _nonEmptyString(metadata['originalTitle']),
tagline: _nonEmptyString(metadata['tagline']),
studios: studios,
countries: countries,
credits: credits,
contentAdvisory: _contentAdvisoryFor(metadata),
budget: flexibleInt(metadata['budget']),
revenue: flexibleInt(metadata['revenue']),
playState: playState.isEmpty ? null : playState,
relevance: relevance,
);
}
static List<String>? _tagsFor(Object? value) {
final tags = <String>[];
final seen = <String>{};
for (final entry in flexibleMapList(value)) {
final tag = _nonEmptyString(entry['tag'] ?? entry['name']);
if (tag != null && seen.add(tag)) tags.add(tag);
}
return tags.isEmpty ? null : tags;
}
static List<String>? _countriesFor(Object? value) {
final countries = <String>[];
final seen = <String>{};
for (final entry in flexibleMapList(value)) {
final tag = _nonEmptyString(entry['tag'] ?? entry['name']);
if (tag == null) continue;
final country = CountryCodes.normalizeCode(tag);
if (country.isNotEmpty && seen.add(country)) countries.add(country);
}
return countries.isEmpty ? null : countries;
}
static List<CatalogCredit>? _creditsFor(Map<String, dynamic> metadata) {
final credits = <CatalogCredit>[];
final seen = <String>{};
void addCredits(Object? value, CatalogCreditRole role) {
for (final entry in flexibleMapList(value)) {
final name = _nonEmptyString(entry['tag'] ?? entry['name'] ?? entry['role']);
if (name != null && seen.add('${role.name}\u0000$name')) {
credits.add(CatalogCredit(name: name, role: role));
}
}
}
addCredits(metadata['Director'], CatalogCreditRole.director);
addCredits(metadata['Writer'], CatalogCreditRole.writer);
addCredits(metadata['Producer'], CatalogCreditRole.producer);
return credits.isEmpty ? null : credits;
}
static List<CatalogRatingSource>? _ratingsFor(Map<String, dynamic> metadata) {
final ratings = <CatalogRatingSource>[];
final imdbVotes = flexibleInt(metadata['imdbRatingCount']);
void add(Object? rawValue, {required String fallbackSource, Object? image, Object? type}) {
final value = _normalizedRating(rawValue);
if (value == null) return;
final source = _ratingSource(image: image, type: type, fallback: fallbackSource);
if (ratings.any((rating) => rating.source == source && rating.value == value)) return;
ratings.add(CatalogRatingSource(source: source, value: value, votes: source == 'imdb' ? imdbVotes : null));
}
add(metadata['rating'], fallbackSource: 'critic', image: metadata['ratingImage']);
add(metadata['audienceRating'], fallbackSource: 'audience', image: metadata['audienceRatingImage']);
for (final rating in flexibleMapList(metadata['Rating'])) {
add(rating['value'], fallbackSource: 'audience', image: rating['image'], type: rating['type']);
}
return ratings.isEmpty ? null : ratings;
}
static String _ratingSource({Object? image, Object? type, required String fallback}) {
final scheme = Uri.tryParse(_nonEmptyString(image) ?? '')?.scheme.toLowerCase();
if (scheme == 'imdb') return 'imdb';
if (scheme == 'themoviedb' || scheme == 'tmdb') return 'tmdb';
final ratingType = _nonEmptyString(type)?.toLowerCase() ?? fallback;
if (scheme == 'rottentomatoes') {
return ratingType == 'critic' ? 'rottenTomatoesCritic' : 'rottenTomatoesAudience';
}
return switch (ratingType) {
'critic' => 'critic',
'audience' => 'audience',
_ => fallback,
};
}
static double? _normalizedRating(Object? value) {
final rating = flexibleDouble(value);
if (rating == null || !rating.isFinite || rating < 0 || rating > 100) return null;
return rating > 10 ? rating / 10 : rating;
}
static String? _overviewFor(Map<String, dynamic> metadata) {
var overview = _nonEmptyString(metadata['summary']);
for (final summary in flexibleMapList(metadata['Summary'])) {
final candidate = _nonEmptyString(summary['tag'] ?? summary['summary']);
if (candidate != null && (overview == null || candidate.length > overview.length)) {
overview = candidate;
}
}
return overview;
}
static String? _contentAdvisoryFor(Map<String, dynamic> metadata) {
final advisories = <String>[];
final seen = <String>{};
for (final commonSense in flexibleMapList(metadata['CommonSenseMedia'])) {
final oneLiner = _nonEmptyString(commonSense['oneLiner']);
int? age;
for (final rating in flexibleMapList(commonSense['AgeRating'])) {
age ??= flexibleInt(rating['age']);
}
final advisory = [if (age != null) '$age+', ?oneLiner].join(' · ');
if (advisory.isNotEmpty && seen.add(advisory)) advisories.add(advisory);
}
return advisories.isEmpty ? null : advisories.join('\n');
}
static DateTime? _date(Object? value) => value is String ? DateTime.tryParse(value) : null;
static MediaKind? _kindFor(Object? type) => switch (type) {
'movie' => MediaKind.movie,
'show' => MediaKind.show,
_ => null,
};
static CatalogHubStyle? _hubStyleFor(Object? style) => switch (style) {
'shelf' => CatalogHubStyle.shelf,
'availabilityPlatforms' => CatalogHubStyle.availabilityPlatforms,
_ => null,
};
static CatalogItemIds _idsFor(Map<String, dynamic> metadata) {
String? imdb;
int? tmdb;
+387 -28
View File
@@ -2,11 +2,16 @@ import 'package:flutter/foundation.dart';
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/seerr/seerr_details.dart';
import '../../models/seerr/seerr_media.dart';
import '../../models/seerr/seerr_page.dart';
import '../../models/seerr/seerr_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../../utils/trailer_urls.dart';
import '../seerr/seerr_client.dart';
import '../seerr/seerr_constants.dart';
import 'catalog_source.dart';
@@ -83,13 +88,23 @@ class SeerrCatalogSource implements CatalogSource {
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final tmdbId = item.ids.tmdb;
if (tmdbId == null) return const [];
final page = item.kind == MediaKind.movie
? await client.getMovieRecommendations(tmdbId)
: await client.getTvRecommendations(tmdbId);
return _toPage(page).items.take(limit).toList();
if (tmdbId == null) return CatalogDetail(item: item);
// Start both retained calls before awaiting either. Each helper isolates
// its own failure so detail metadata and recommendations degrade
// independently.
final detailsFuture = _fetchDetails(item.kind, tmdbId);
final relatedFuture = _fetchRecommendations(item.kind, tmdbId, relatedLimit);
final details = await detailsFuture;
final related = await relatedFuture;
return CatalogDetail(
item: details == null ? item : item.enrichedWith(_toDetailCatalogItem(details, item.kind)),
cast: details == null ? const [] : _toCast(details, castLimit),
related: related,
);
}
/// Seerr requests key on TMDB ids, so any library item carrying one is in
@@ -99,27 +114,6 @@ class SeerrCatalogSource implements CatalogSource {
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
external.tmdb == null ? null : CatalogItemIds(tmdb: external.tmdb, imdb: external.imdb, tvdb: external.tvdb);
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
final tmdbId = item.ids.tmdb;
if (tmdbId == null) return const [];
final SeerrCredits? credits;
if (item.kind == MediaKind.movie) {
credits = (await client.getMovie(tmdbId)).credits;
} else {
credits = (await client.getTv(tmdbId)).credits;
}
return [
for (final member in (credits?.cast ?? const <SeerrCastMember>[]).take(limit))
if (member.name case final String name when name.isNotEmpty)
CatalogCastMember(
name: name,
secondary: member.character,
imageUrl: tmdbImageUrl(member.profilePath, 'w300'),
),
];
}
// Seerr has no watchlist: membership is always unknown and mutations are
// programming errors (the action is hidden when supportsWatchlist is false).
@@ -142,6 +136,7 @@ class SeerrCatalogSource implements CatalogSource {
if (m.displayTitle.isNotEmpty) _toCatalogItem(m),
],
hasMore: page.hasMore,
totalResults: page.totalResults,
);
CatalogItem _toCatalogItem(SeerrMedia m) => CatalogItem(
@@ -149,14 +144,378 @@ class SeerrCatalogSource implements CatalogSource {
kind: m.isMovie ? MediaKind.movie : MediaKind.show,
title: m.displayTitle,
year: m.year,
overview: m.overview,
overview: _nonEmpty(m.overview),
rating: m.voteAverage,
votes: m.voteCount,
ids: CatalogItemIds(tmdb: m.id),
posterUrl: tmdbImageUrl(m.posterPath, 'w600_and_h900_bestv2'),
backdropUrl: tmdbImageUrl(m.backdropPath, 'w1920_and_h800_multi_faces'),
posterVariants: tmdbPosterVariants(m.posterPath),
backdropVariants: tmdbBackdropVariants(m.backdropPath),
serverState: _serverState(m.mediaInfo),
releaseDate: _date(m.date),
originalTitle: _originalTitle(m.displayOriginalTitle, m.displayTitle),
languages: _languageList(m.originalLanguage),
countries: _countryCodes(m.originCountry),
isAdult: m.isMovie ? m.adult : null,
relevance: m.popularity,
);
Future<SeerrDetails?> _fetchDetails(MediaKind kind, int tmdbId) async {
try {
return kind == MediaKind.movie ? await client.getMovie(tmdbId) : await client.getTv(tmdbId);
} catch (error) {
appLogger.w('Seerr: detail load failed for tmdb:$tmdbId', error: error);
return null;
}
}
Future<List<CatalogItem>> _fetchRecommendations(MediaKind kind, int tmdbId, int limit) async {
try {
final page = kind == MediaKind.movie
? await client.getMovieRecommendations(tmdbId)
: await client.getTvRecommendations(tmdbId);
return _toPage(page).items.take(limit).toList();
} catch (error) {
appLogger.w('Seerr: recommendations load failed for tmdb:$tmdbId', error: error);
return const [];
}
}
CatalogItem _toDetailCatalogItem(SeerrDetails details, MediaKind kind) => CatalogItem(
source: CatalogSourceId.seerr,
kind: kind,
title: details.displayTitle,
year: _year(details.date),
overview: _nonEmpty(details.overview),
runtimeMinutes: _positive(details.runtime) ?? _firstPositive(details.episodeRunTime),
rating: details.voteAverage,
votes: details.voteCount,
genres: _names(details.genres),
certification: _certification(details, kind),
trailerUrl: kind == MediaKind.movie ? _trailerUrl(details.relatedVideos) : null,
airStatus: _airStatus(details.status, kind),
episodeCount: kind == MediaKind.show ? _episodeCount(details) : null,
network: kind == MediaKind.show ? _firstName(details.networks) : null,
ids: CatalogItemIds(
tmdb: _positive(details.id),
imdb: _nonEmpty(details.imdbId) ?? _nonEmpty(details.externalIds?.imdbId),
tvdb: _positive(details.externalIds?.tvdbId),
),
posterUrl: tmdbImageUrl(details.posterPath, 'w600_and_h900_bestv2'),
backdropUrl: tmdbImageUrl(details.backdropPath, 'w1920_and_h800_multi_faces'),
posterVariants: tmdbPosterVariants(details.posterPath),
backdropVariants: tmdbBackdropVariants(details.backdropPath),
serverState: _serverState(details.mediaInfo),
releaseDate: _date(details.date),
originalTitle: _originalTitle(details.displayOriginalTitle, details.displayTitle),
tagline: _nonEmpty(details.tagline),
endDate: _endDate(details, kind),
studios: _names(details.productionCompanies),
countries: _detailCountries(details),
languages: _detailLanguages(details),
credits: _credits(details),
tags: _tags(details.keywords),
budget: _positive(details.budget),
revenue: _positive(details.revenue),
isAdult: kind == MediaKind.movie ? details.adult : null,
relevance: details.popularity,
);
List<CatalogCastMember> _toCast(SeerrDetails details, int limit) => [
for (final member in (details.credits?.cast ?? const <SeerrCastMember>[]).take(limit))
if (_nonEmpty(member.name) case final String name)
CatalogCastMember(
name: name,
secondary: _nonEmpty(member.character),
imageUrl: tmdbImageUrl(member.profilePath, 'w300'),
),
];
static CatalogServerState? _serverState(SeerrMediaInfo? info) {
if (info == null) return null;
int? availableSeasons;
int? totalSeasons;
final seasons = info.seasons;
if (seasons != null && seasons.isNotEmpty) {
var available = 0;
var total = 0;
for (final season in seasons) {
if (season.seasonNumber <= 0) continue;
total++;
if (season.status == SeerrMediaStatus.available) available++;
}
if (total > 0) {
availableSeasons = available;
totalSeasons = total;
}
}
final state = CatalogServerState(
availability: _availability(info.status),
availability4k: _availability(info.status4k),
request: _requestState(info, is4k: false, mediaStatus: info.status),
request4k: _requestState(info, is4k: true, mediaStatus: info.status4k),
availableSeasons: availableSeasons,
totalSeasons: totalSeasons,
);
return state.isEmpty ? null : state;
}
static CatalogAvailability? _availability(SeerrMediaStatus status) => switch (status) {
SeerrMediaStatus.available => CatalogAvailability.available,
SeerrMediaStatus.partiallyAvailable => CatalogAvailability.partiallyAvailable,
SeerrMediaStatus.pending ||
SeerrMediaStatus.processing ||
SeerrMediaStatus.deleted => CatalogAvailability.unavailable,
SeerrMediaStatus.unknown => null,
};
static CatalogRequestState? _requestState(
SeerrMediaInfo info, {
required bool is4k,
required SeerrMediaStatus mediaStatus,
}) {
var pendingApproval = false;
var approved = false;
var declined = false;
for (final request in info.requests ?? const <SeerrRequest>[]) {
if ((request.is4k ?? false) != is4k) continue;
switch (request.status) {
case SeerrRequestStatus.pending:
pendingApproval = true;
case SeerrRequestStatus.approved:
approved = true;
case SeerrRequestStatus.declined:
declined = true;
}
}
// These two `pending` values are unrelated. Request.pending means waiting
// for approval; MediaInfo.pending means an approved request is queued in
// the acquisition pipeline. Keep that distinction and precedence explicit.
if (pendingApproval) return CatalogRequestState.pending;
if (mediaStatus == SeerrMediaStatus.processing) return CatalogRequestState.processing;
if (approved || mediaStatus == SeerrMediaStatus.pending) return CatalogRequestState.approved;
if (declined) return CatalogRequestState.declined;
return null;
}
static String? _certification(SeerrDetails details, MediaKind kind) {
if (kind == MediaKind.movie) {
return _preferredCountryValue(
details.releases?.results,
(entry) => entry.countryCode,
(entry) =>
_nonEmpty(entry.rating) ??
entry.releaseDates?.map((date) => _nonEmpty(date.certification)).nonNulls.firstOrNull,
);
}
return _preferredCountryValue(
details.contentRatings?.results,
(entry) => entry.countryCode,
(entry) => _nonEmpty(entry.rating),
);
}
static String? _preferredCountryValue<T>(
List<T>? entries,
String? Function(T entry) country,
String? Function(T entry) value,
) {
if (entries == null) return null;
for (final entry in entries) {
if (country(entry)?.toUpperCase() != 'US') continue;
if (value(entry) case final String result) return result;
}
for (final entry in entries) {
if (value(entry) case final String result) return result;
}
return null;
}
static String? _trailerUrl(List<SeerrRelatedVideo>? videos) {
if (videos == null) return null;
for (final video in videos) {
if (video.type?.toLowerCase() != 'trailer') continue;
if (_nonEmpty(video.url) case final String url) return url;
if (_nonEmpty(video.key) case final String key when video.site?.toLowerCase() == 'youtube') {
return youTubeTrailerUrl(key);
}
}
return null;
}
static CatalogAirStatus? _airStatus(String? raw, MediaKind kind) {
return switch (raw?.trim().toLowerCase()) {
'returning series' || 'airing' => CatalogAirStatus.airing,
'ended' => CatalogAirStatus.ended,
'canceled' || 'cancelled' => CatalogAirStatus.canceled,
'planned' || 'pilot' || 'rumored' || 'in production' || 'post production' => CatalogAirStatus.upcoming,
'released' when kind == MediaKind.show => CatalogAirStatus.ended,
_ => null,
};
}
static DateTime? _endDate(SeerrDetails details, MediaKind kind) {
if (kind != MediaKind.show) return null;
final status = _airStatus(details.status, kind);
return status == CatalogAirStatus.ended || status == CatalogAirStatus.canceled ? _date(details.lastAirDate) : null;
}
static int? _episodeCount(SeerrDetails details) {
if (_positive(details.numberOfEpisodes) case final int count) return count;
var total = 0;
var found = false;
for (final season in details.seasons ?? const <SeerrSeason>[]) {
if (_positive(season.episodeCount) case final int count) {
total += count;
found = true;
}
}
return found ? total : null;
}
static List<String>? _countryCodes(Iterable<String?>? values) {
if (values == null) return null;
final result = <String>[];
final seen = <String>{};
for (final value in values) {
final trimmed = _nonEmpty(value);
if (trimmed == null) continue;
final normalized = CountryCodes.normalizeCode(trimmed);
if (normalized.isNotEmpty && seen.add(normalized)) result.add(normalized);
}
return result.isEmpty ? null : result;
}
static List<String>? _detailCountries(SeerrDetails details) => _countryCodes([
...?details.originCountry,
for (final value in details.productionCountries ?? const <SeerrProductionCountry>[])
value.countryCode ?? value.name,
]);
static List<String>? _detailLanguages(SeerrDetails details) => _languageCodes([
details.originalLanguage,
...?details.languages,
for (final language in details.spokenLanguages ?? const <SeerrSpokenLanguage>[]) language.languageCode,
]);
static List<String>? _languageCodes(Iterable<String?> values) {
final result = <String>[];
final seen = <String>{};
for (final value in values) {
final normalized = _nonEmpty(value)?.toLowerCase();
if (normalized != null && seen.add(normalized)) result.add(normalized);
}
return result.isEmpty ? null : result;
}
static List<CatalogCredit>? _credits(SeerrDetails details) {
final result = <CatalogCredit>[];
final seen = <String>{};
void add(String? rawName, CatalogCreditRole? role) {
final name = _nonEmpty(rawName);
if (name == null || role == null || !seen.add('${role.name}\u0000$name')) return;
result.add(CatalogCredit(name: name, role: role));
}
for (final creator in details.createdBy ?? const <SeerrNamedValue>[]) {
add(creator.name, CatalogCreditRole.creator);
}
for (final member in details.credits?.crew ?? const <SeerrCrewMember>[]) {
add(member.name, _creditRole(member.job, member.department));
}
return result.isEmpty ? null : result;
}
static CatalogCreditRole? _creditRole(String? job, String? department) {
return switch (job?.trim().toLowerCase()) {
'director' => CatalogCreditRole.director,
'writer' || 'screenplay' || 'story' || 'teleplay' => CatalogCreditRole.writer,
'producer' || 'executive producer' || 'co-producer' => CatalogCreditRole.producer,
'composer' || 'original music composer' => CatalogCreditRole.composer,
_ when department?.trim().toLowerCase() == 'writing' => CatalogCreditRole.writer,
_ => null,
};
}
static List<CatalogTag>? _tags(List<SeerrNamedValue>? keywords) {
final names = _names(keywords);
return names == null ? null : [for (final name in names) CatalogTag(name: name)];
}
static List<String>? _names(List<SeerrNamedValue>? values) {
if (values == null) return null;
final names = [
for (final value in values)
if (_nonEmpty(value.name) case final String name) name,
];
return names.isEmpty ? null : names;
}
static String? _firstName(List<SeerrNamedValue>? values) {
if (values == null) return null;
for (final value in values) {
if (_nonEmpty(value.name) case final String name) return name;
}
return null;
}
static List<String>? _languageList(String? language) => _languageCodes([language]);
static String? _originalTitle(String? original, String displayed) {
final value = _nonEmpty(original);
return value == null || value == displayed ? null : value;
}
static DateTime? _date(String? raw) => raw == null ? null : DateTime.tryParse(raw);
static int? _year(String? raw) {
if (raw == null || raw.length < 4) return null;
return int.tryParse(raw.substring(0, 4));
}
static int? _positive(int? value) => value != null && value > 0 ? value : null;
static int? _firstPositive(List<int>? values) {
if (values == null) return null;
for (final value in values) {
if (value > 0) return value;
}
return null;
}
static String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
static Map<int, String>? tmdbPosterVariants(String? path) {
if (path == null || path.isEmpty) return null;
return {
92: tmdbImageUrl(path, 'w92')!,
154: tmdbImageUrl(path, 'w154')!,
185: tmdbImageUrl(path, 'w185')!,
342: tmdbImageUrl(path, 'w342')!,
500: tmdbImageUrl(path, 'w500')!,
600: tmdbImageUrl(path, 'w600_and_h900_bestv2')!,
780: tmdbImageUrl(path, 'w780')!,
};
}
static Map<int, String>? tmdbBackdropVariants(String? path) {
if (path == null || path.isEmpty) return null;
return {
300: tmdbImageUrl(path, 'w300')!,
780: tmdbImageUrl(path, 'w780')!,
1280: tmdbImageUrl(path, 'w1280')!,
1920: tmdbImageUrl(path, 'w1920_and_h800_multi_faces')!,
};
}
/// Seerr serves TMDB relative paths (`/abc.jpg`); images come straight off
/// the TMDB CDN at the same sizes the Seerr web UI uses.
static String? tmdbImageUrl(String? path, String size) =>
+298 -29
View File
@@ -1,18 +1,21 @@
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/simkl/simkl_all_items_entry.dart';
import '../../models/simkl/simkl_best_item.dart';
import '../../models/simkl/simkl_detail.dart';
import '../../models/simkl/simkl_ids.dart';
import '../../models/simkl/simkl_images.dart';
import '../../models/simkl/simkl_recommendation.dart';
import '../../models/simkl/simkl_rating.dart';
import '../../models/simkl/simkl_search_result.dart';
import '../../models/simkl/simkl_trending_item.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../trackers/simkl/simkl_client.dart';
import '../trackers/simkl/simkl_constants.dart';
import '../trackers/tracker_exceptions.dart';
import '../trackers/future_coalescer.dart';
import '../../utils/trailer_urls.dart';
import 'catalog_source.dart';
import 'catalog_watchlist_machinery.dart';
@@ -29,6 +32,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
final Map<CatalogRowId, DateTime> _rowCacheLoadedAt = {};
final KeyedFutureCoalescer<CatalogRowId, List<CatalogItem>> _rowLoads = KeyedFutureCoalescer();
final FutureCoalescer<SimklAllItems> _watchlistLoad = FutureCoalescer();
final Set<int> _animeIds = {};
SimklAllItems? _watchlistCache;
int _watchlistCacheGeneration = 0;
@@ -170,7 +174,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
static CatalogAirStatus? airStatusFor(String? status, MediaKind kind) => switch (status?.toLowerCase()) {
'airing' || 'ongoing' => CatalogAirStatus.airing,
'ended' => kind == MediaKind.movie ? null : CatalogAirStatus.ended,
'tba' => CatalogAirStatus.upcoming,
'premiere' || 'tba' => CatalogAirStatus.upcoming,
_ => null,
};
@@ -180,13 +184,187 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
return MediaKind.show;
}
static CatalogFormat? _formatFor(SimklCatalogType type, String? raw) {
if (type != SimklCatalogType.anime) return null;
return switch (raw?.trim().toLowerCase()) {
'tv' => CatalogFormat.tv,
'tv short' || 'tv_short' || 'tv-short' => CatalogFormat.tvShort,
'movie' => CatalogFormat.movie,
'special' => CatalogFormat.special,
'ova' => CatalogFormat.ova,
'ona' => CatalogFormat.ona,
'music' || 'music video' => CatalogFormat.music,
null || '' => null,
_ => CatalogFormat.other,
};
}
SimklCatalogType _detailTypeFor(CatalogItem item) {
if (item.format != null || _animeIds.contains(item.ids.simkl) || item.ids.mal != null || item.ids.anilist != null) {
return SimklCatalogType.anime;
}
return item.kind == MediaKind.movie ? SimklCatalogType.movies : SimklCatalogType.tv;
}
void _rememberType(SimklCatalogType type, SimklIds ids) {
final simklId = ids.simkl;
if (type == SimklCatalogType.anime && simklId != null) _animeIds.add(simklId);
}
static List<CatalogRatingSource>? _ratingsFor(SimklRatings? ratings) {
if (ratings == null) return null;
final result = <CatalogRatingSource>[];
for (final (source, rating) in [('simkl', ratings.simkl), ('imdb', ratings.imdb), ('mal', ratings.mal)]) {
final value = rating?.rating;
if (value == null || value < 0 || value > 10) continue;
final votes = rating!.votes;
result.add(CatalogRatingSource(source: source, value: value, votes: votes != null && votes >= 0 ? votes : null));
}
return result.isEmpty ? null : result;
}
static CatalogAudience? _audienceFor({
int? viewers,
CatalogAudiencePeriod? viewersPeriod,
int? planning,
String? dropRate,
}) {
final normalizedViewers = viewers != null && viewers >= 0 ? viewers : null;
final normalizedPlanning = planning != null && planning >= 0 ? planning : null;
final normalizedDropRate = _percentFraction(dropRate);
if (normalizedViewers == null && normalizedPlanning == null && normalizedDropRate == null) return null;
return CatalogAudience(
viewers: normalizedViewers,
viewersPeriod: normalizedViewers == null ? null : viewersPeriod,
planning: normalizedPlanning,
dropRate: normalizedDropRate,
);
}
static double? _percentFraction(String? raw) {
final value = double.tryParse(raw?.trim().replaceAll('%', '') ?? '');
if (value == null || value < 0 || value > 100) return null;
return value / 100;
}
static List<CatalogRank>? _rankFor(int? rank, CatalogRankScope scope, {required bool allTime}) =>
rank == null || rank <= 0 ? null : [CatalogRank(rank: rank, scope: scope, allTime: allTime)];
static String? _originalTitle(String title, String? raw) {
final value = raw?.trim();
if (value == null || value.isEmpty || value.toLowerCase() == title.trim().toLowerCase()) return null;
return value;
}
static List<String>? _alternateTitles(String title, List<String>? raw, {String? originalTitle, String? extra}) {
final seen = <String>{title.trim().toLowerCase(), if (originalTitle != null) originalTitle.toLowerCase()};
final result = <String>[];
for (final candidate in [...?raw, ?extra]) {
final value = candidate.trim();
if (value.isEmpty || !seen.add(value.toLowerCase())) continue;
result.add(value);
}
return result.isEmpty ? null : result;
}
static List<CatalogLink>? _linksFor(String? raw) {
final parsed = Uri.tryParse(raw?.trim() ?? '');
if (parsed == null || parsed.toString().isEmpty) return null;
final resolved = parsed.hasScheme ? parsed : Uri.parse('https://simkl.com').resolveUri(parsed);
if (resolved.scheme != 'http' && resolved.scheme != 'https') return null;
return [CatalogLink(label: 'Simkl', url: resolved.toString())];
}
static DateTime? _date(String? raw) {
final value = raw?.trim();
if (value == null || value.isEmpty) return null;
final iso = DateTime.tryParse(value);
if (iso != null) return iso;
final match = RegExp(r'^(\d{1,2})/(\d{1,2})/(\d{4})$').firstMatch(value);
if (match == null) return null;
final month = int.parse(match.group(1)!);
final day = int.parse(match.group(2)!);
final year = int.parse(match.group(3)!);
return DateTime.tryParse(
'${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}',
);
}
static List<String>? _countryFor(String? raw) {
final value = raw?.trim();
return value == null || value.isEmpty ? null : [CountryCodes.normalizeCode(value)];
}
static List<String>? _languageFor(String? raw) {
final value = raw?.trim();
return value == null || value.isEmpty ? null : [value.toLowerCase()];
}
static List<CatalogCredit>? _creditsFor(String? director) {
final name = director?.trim();
return name == null || name.isEmpty ? null : [CatalogCredit(name: name, role: CatalogCreditRole.director)];
}
static CatalogBroadcast? _broadcastFor(SimklAirs? airs) {
if (airs == null) return null;
final weekday = _weekdayFor(airs.day);
final time = _broadcastTime(airs.time);
if (weekday == null && time == null) return null;
final timezone = airs.timezone?.trim();
return CatalogBroadcast(
weekday: weekday,
time: time,
timezone: timezone == null || timezone.isEmpty ? null : timezone,
);
}
static int? _weekdayFor(String? raw) => switch (raw?.trim().toLowerCase()) {
'monday' => DateTime.monday,
'tuesday' => DateTime.tuesday,
'wednesday' => DateTime.wednesday,
'thursday' => DateTime.thursday,
'friday' => DateTime.friday,
'saturday' => DateTime.saturday,
'sunday' => DateTime.sunday,
_ => null,
};
static String? _broadcastTime(String? raw) {
final match = RegExp(r'^(\d{1,2}):(\d{2})\s*([AP]M)?$', caseSensitive: false).firstMatch(raw?.trim() ?? '');
if (match == null) return null;
var hour = int.parse(match.group(1)!);
final minute = int.parse(match.group(2)!);
final period = match.group(3)?.toUpperCase();
if (minute > 59 || (period == null && hour > 23) || (period != null && (hour < 1 || hour > 12))) {
return null;
}
if (period != null) {
hour %= 12;
if (period == 'PM') hour += 12;
}
return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
}
static CatalogSeasonInfo? _seasonFor(String? raw) {
final parts = raw?.trim().split(RegExp(r'\s+'));
if (parts == null || parts.isEmpty) return null;
final name = CatalogSeasonInfo.parseName(parts.first);
if (name == null) return null;
return CatalogSeasonInfo(name: name, year: parts.length > 1 ? int.tryParse(parts.last) : null);
}
static String? _trailerUrl(String? youtube) => youTubeTrailerUrl(youtube);
CatalogItem _catalogItemFromTrending(SimklTrendingItem item, SimklCatalogType type) {
_rememberType(type, item.ids);
final kind = _kindFor(type, animeType: item.animeType);
final rating = item.ratings?.primary;
final title = item.title ?? '';
final originalTitle = _originalTitle(title, item.titleRomaji);
return CatalogItem(
source: CatalogSourceId.simkl,
kind: kind,
title: item.title ?? '',
title: title,
year: item.year,
overview: item.overview,
runtimeMinutes: item.runtimeMinutes,
@@ -200,16 +378,37 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
ids: item.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(item.poster),
backdropUrl: simklFanartUrl(item.fanart),
posterVariants: simklPosterVariants(item.poster),
backdropVariants: simklFanartVariants(item.fanart),
ratings: _ratingsFor(item.ratings),
ranks: _rankFor(item.rank, CatalogRankScope.trending, allTime: false),
audience: _audienceFor(
viewers: item.watched,
viewersPeriod: CatalogAudiencePeriod.week,
planning: item.planToWatch,
dropRate: item.dropRate,
),
releaseDate: _date(item.theater ?? item.releaseDate),
physicalReleaseDate: _date(item.dvdDate),
originalTitle: originalTitle,
altTitles: _alternateTitles(title, item.alternateTitles, originalTitle: originalTitle) ?? const [],
format: _formatFor(type, item.animeType),
countries: _countryFor(item.country),
languages: _languageFor(item.originalLanguage),
links: _linksFor(item.url),
);
}
CatalogItem _catalogItemFromSearch(SimklSearchResult item, SimklCatalogType type) {
_rememberType(type, item.ids);
final kind = _kindFor(type, animeType: item.type);
final rating = item.ratings?.primary;
final title = item.title ?? '';
final originalTitle = _originalTitle(title, item.titleRomaji);
return CatalogItem(
source: CatalogSourceId.simkl,
kind: kind,
title: item.title ?? '',
title: title,
year: item.year,
rating: rating?.rating,
votes: rating?.votes,
@@ -217,10 +416,17 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
episodeCount: kind == MediaKind.movie || (item.episodeCount ?? 0) <= 0 ? null : item.episodeCount,
ids: item.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(item.poster),
posterVariants: simklPosterVariants(item.poster),
ratings: _ratingsFor(item.ratings),
originalTitle: originalTitle,
altTitles: _alternateTitles(title, item.allTitles, originalTitle: originalTitle, extra: item.titleEn) ?? const [],
format: _formatFor(type, item.type),
links: _linksFor(item.url),
);
}
CatalogItem _catalogItemFromBest(SimklBestItem item, SimklCatalogType type) {
_rememberType(type, item.ids);
final kind = _kindFor(type);
final rating = item.ratings?.primary;
return CatalogItem(
@@ -232,11 +438,17 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
votes: rating?.votes,
ids: item.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(item.poster),
posterVariants: simklPosterVariants(item.poster),
ratings: _ratingsFor(item.ratings),
audience: _audienceFor(viewers: item.watched, viewersPeriod: CatalogAudiencePeriod.month),
format: _formatFor(type, null),
links: _linksFor(item.url),
);
}
CatalogItem _catalogItemFromAllItems(SimklAllItemsEntry entry, SimklCatalogType type) {
final media = entry.media!;
_rememberType(type, media.ids);
final kind = _kindFor(type, animeType: entry.animeType);
return CatalogItem(
source: CatalogSourceId.simkl,
@@ -252,46 +464,102 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
ids: media.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(media.poster),
backdropUrl: simklFanartUrl(media.fanart),
posterVariants: simklPosterVariants(media.poster),
backdropVariants: simklFanartVariants(media.fanart),
addedAt: _date(entry.addedAt),
userRating: entry.userRating,
format: _formatFor(type, entry.animeType),
unairedEpisodeCount: kind == MediaKind.movie || (entry.notAiredEpisodes ?? 0) <= 0
? null
: entry.notAiredEpisodes,
);
}
CatalogItem _catalogItemFromRecommendation(SimklRecommendation item) {
final kind = item.type == 'movie' ? MediaKind.movie : MediaKind.show;
final type = switch (item.type?.toLowerCase()) {
'movie' => SimklCatalogType.movies,
'anime' => SimklCatalogType.anime,
_ => SimklCatalogType.tv,
};
_rememberType(type, item.ids);
final kind = _kindFor(type, animeType: item.animeType);
final title = item.title ?? '';
return CatalogItem(
source: CatalogSourceId.simkl,
kind: kind,
title: item.title ?? '',
title: title,
year: item.year,
ids: item.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(item.poster),
posterVariants: simklPosterVariants(item.poster),
altTitles: _alternateTitles(title, null, extra: item.englishTitle) ?? const [],
format: _formatFor(type, item.animeType),
recommendationCount: item.usersCount != null && item.usersCount! >= 0 ? item.usersCount : null,
recommendationPercent: _percentFraction(item.usersPercent),
);
}
CatalogItem _catalogItemFromDetail(SimklDetail detail, SimklCatalogType type) {
final kind = _kindFor(type, animeType: detail.animeType);
final rating = detail.ratings?.primary;
final title = detail.title ?? '';
final originalTitle = _originalTitle(title, detail.originalTitle);
return CatalogItem(
source: CatalogSourceId.simkl,
kind: kind,
title: title,
year: detail.year,
overview: detail.overview,
runtimeMinutes: detail.runtime,
rating: rating?.rating,
votes: rating?.votes,
genres: detail.genres,
certification: detail.certification,
trailerUrl: _trailerUrl(detail.trailer),
airStatus: airStatusFor(detail.status, kind),
episodeCount: kind == MediaKind.movie || (detail.totalEpisodes ?? 0) <= 0 ? null : detail.totalEpisodes,
network: detail.network,
ids: detail.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(detail.poster),
backdropUrl: simklFanartUrl(detail.fanart),
posterVariants: simklPosterVariants(detail.poster),
backdropVariants: simklFanartVariants(detail.fanart),
ratings: _ratingsFor(detail.ratings),
ranks: _rankFor(detail.rank, CatalogRankScope.popular, allTime: true),
audience: _audienceFor(dropRate: detail.dropRate),
broadcast: _broadcastFor(detail.airs),
releaseDate: _date(detail.released ?? detail.firstAired),
physicalReleaseDate: _date(detail.physicalRelease),
endDate: _date(detail.lastAired),
originalTitle: originalTitle,
altTitles:
_alternateTitles(title, detail.alternateTitles, originalTitle: originalTitle, extra: detail.englishTitle) ??
const [],
broadcastSeason: _seasonFor(detail.seasonNameYear),
format: _formatFor(type, detail.animeType),
studios: detail.studios,
countries: _countryFor(detail.country),
languages: _languageFor(detail.language),
credits: _creditsFor(detail.director),
links: _linksFor(detail.url),
budget: detail.budget,
revenue: detail.revenue,
);
}
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final simklId = item.ids.simkl;
if (simklId == null) return const [];
final primary = item.kind == MediaKind.movie ? SimklCatalogType.movies : SimklCatalogType.tv;
List<SimklRecommendation> recommendations;
try {
recommendations = await _client.getRecommendations(primary, simklId);
// Current Simkl detail endpoints return `[]` (HTTP 200), not 404, for
// an ID belonging to a different media bucket. Anime is therefore the
// fallback for both an explicit 404 and an empty primary response.
if (recommendations.isEmpty) {
recommendations = await _client.getRecommendations(SimklCatalogType.anime, simklId);
}
} on TrackerApiException catch (error) {
if (error.statusCode != 404) rethrow;
recommendations = await _client.getRecommendations(SimklCatalogType.anime, simklId);
}
return [
for (final recommendation in recommendations.take(limit))
if (recommendation.ids.hasAny) _catalogItemFromRecommendation(recommendation),
if (simklId == null) return CatalogDetail(item: item);
final type = _detailTypeFor(item);
final detail = await _client.getDetail(type, simklId);
if (detail == null) return CatalogDetail(item: item);
final related = <CatalogItem>[
if (relatedLimit > 0)
for (final recommendation in detail.recommendations.take(relatedLimit))
if (recommendation.ids.hasAny) _catalogItemFromRecommendation(recommendation),
];
return CatalogDetail(item: item.enrichedWith(_catalogItemFromDetail(detail, type)), related: related);
}
@override
@@ -354,6 +622,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
_invalidateWatchlistCache();
_rowCache.clear();
_rowCacheLoadedAt.clear();
_animeIds.clear();
disposeWatchlistMachinery();
}
}
+220 -41
View File
@@ -1,9 +1,14 @@
import '../../i18n/strings.g.dart';
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/trakt/trakt_cast_entry.dart';
import '../../models/trakt/trakt_catalog_entry.dart';
import '../../models/trakt/trakt_catalog_media.dart';
import '../../models/trakt/trakt_ids.dart';
import '../../utils/app_logger.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../trakt/trakt_client.dart';
import '../trakt/trakt_constants.dart';
@@ -54,7 +59,7 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
switch (row) {
case CatalogRowId.watchlist:
final res = await _client.getWatchlist(page: page, limit: limit);
return CatalogPage(items: _fromEntries(res.items), hasMore: res.hasMore);
return CatalogPage(items: _fromEntries(res.items), hasMore: res.hasMore, totalResults: res.itemCount);
case CatalogRowId.recommendedMovies:
return CatalogPage(
items: _fromMedia(await _client.getRecommended(TraktCatalogType.movies, limit: limit), MediaKind.movie),
@@ -68,19 +73,29 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
return CatalogPage(
items: _fromEntries(res.items, kind: MediaKind.movie),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.trendingShows:
final res = await _client.getTrending(TraktCatalogType.shows, page: page, limit: limit);
return CatalogPage(
items: _fromEntries(res.items, kind: MediaKind.show),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.popularMovies:
final res = await _client.getPopular(TraktCatalogType.movies, page: page, limit: limit);
return CatalogPage(items: _fromMedia(res.items, MediaKind.movie), hasMore: res.hasMore);
return CatalogPage(
items: _fromMedia(res.items, MediaKind.movie),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.popularShows:
final res = await _client.getPopular(TraktCatalogType.shows, page: page, limit: limit);
return CatalogPage(items: _fromMedia(res.items, MediaKind.show), hasMore: res.hasMore);
return CatalogPage(
items: _fromMedia(res.items, MediaKind.show),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.trendingAnime:
case CatalogRowId.suggestedAnime:
case CatalogRowId.airingAnime:
@@ -105,28 +120,107 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final id = item.ids.trakt?.toString() ?? item.ids.slug ?? item.ids.imdb;
if (id == null) return const [];
if (id == null) return CatalogDetail(item: item);
final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows;
final cast = await _client.getPeople(type, id);
return [
for (final entry in cast.take(limit))
if (entry.person?.name case final String name when name.isNotEmpty)
CatalogCastMember(
name: name,
secondary: entry.characters?.firstOrNull,
imageUrl: entry.person?.images?.primaryHeadshot,
),
];
// Start both requests before awaiting either. Trakt keeps people and
// related titles on separate endpoints, so two calls are the minimum.
final peopleFuture = _withoutFailure(_client.getPeople(type, id), 'people', item);
final relatedFuture = _withoutFailure(_client.getRelated(type, id, limit: relatedLimit), 'related', item);
final people = await peopleFuture;
final related = await relatedFuture;
final credits = _creditsFrom(people?.crew);
final enrichedItem = credits.isEmpty
? item
: item.enrichedWith(
CatalogItem(
source: CatalogSourceId.trakt,
kind: item.kind,
title: '',
ids: const CatalogItemIds(),
credits: credits,
),
);
return CatalogDetail(
item: enrichedItem,
cast: _castFrom(people, castLimit),
related: related == null ? const [] : _fromMedia(related, item.kind),
);
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
final id = item.ids.trakt?.toString() ?? item.ids.slug ?? item.ids.imdb;
if (id == null) return const [];
final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows;
return _fromMedia(await _client.getRelated(type, id, limit: limit), item.kind);
Future<T?> _withoutFailure<T>(Future<T> request, String requestName, CatalogItem item) async {
try {
return await request;
} catch (error, stackTrace) {
appLogger.d(
'Trakt: catalog $requestName load failed for ${item.identityKey}',
error: error,
stackTrace: stackTrace,
);
return null;
}
}
List<CatalogCastMember> _castFrom(TraktPeople? people, int limit) {
if (people == null || limit <= 0) return const [];
final cast = <CatalogCastMember>[];
// `guest_stars` can be a very large response. Regular cast stays first and
// the combined retained list is bounded even though Trakt sends all rows.
for (final entry in people.cast.followedBy(people.guestStars)) {
if (cast.length >= limit) break;
if (entry.person?.name case final String name when name.isNotEmpty) {
cast.add(
CatalogCastMember(
name: name,
secondary: _castSecondary(entry),
imageUrl: entry.person?.images?.primaryHeadshot,
),
);
}
}
return cast;
}
static String? _castSecondary(TraktCastEntry entry) {
final characters = entry.characters?.where((character) => character.isNotEmpty).join(', ');
final episodeCount = entry.episodeCount;
final parts = [
if (characters != null && characters.isNotEmpty) characters,
if (episodeCount != null) t.explore.badge.episodesShort(n: episodeCount),
];
return parts.isEmpty ? null : parts.join(' · ');
}
static List<CatalogCredit> _creditsFrom(List<TraktCastEntry>? crew) {
if (crew == null || crew.isEmpty) return const [];
final credits = <CatalogCredit>[];
final seen = <String>{};
for (final role in const [CatalogCreditRole.director, CatalogCreditRole.writer, CatalogCreditRole.producer]) {
for (final entry in crew) {
final name = entry.person?.name;
if (name == null || name.isEmpty || !_hasCreditRole(entry, role)) continue;
final key = '${role.name}/${name.toLowerCase()}';
if (seen.add(key)) credits.add(CatalogCredit(name: name, role: role));
}
}
return credits;
}
static bool _hasCreditRole(TraktCastEntry entry, CatalogCreditRole role) {
final job = entry.job;
final jobs = <String>[
if (job != null) job.toLowerCase(),
for (final job in entry.jobs ?? const <String>[]) job.toLowerCase(),
];
return switch (role) {
CatalogCreditRole.director => jobs.any((job) => job == 'director' || job == 'co-director'),
CatalogCreditRole.writer => jobs.any((job) => const {'writer', 'screenplay', 'story', 'teleplay'}.contains(job)),
CatalogCreditRole.producer => jobs.any((job) => job == 'producer' || job.endsWith(' producer')),
_ => false,
};
}
@override
@@ -156,7 +250,12 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
List<CatalogItem> _fromEntries(List<TraktCatalogEntry> entries, {MediaKind? kind}) => [
for (final entry in entries)
if (entry.media != null && _entryKind(entry, kind) != null && entry.media!.ids.hasAny)
_toCatalogItem(entry.media!, _entryKind(entry, kind)!),
_toCatalogItem(
entry.media!,
_entryKind(entry, kind)!,
watchers: entry.watchers,
addedAt: _parseDate(entry.listedAt),
),
];
List<CatalogItem> _fromMedia(List<TraktCatalogMedia> media, MediaKind kind) => [
@@ -191,25 +290,105 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
_ => null,
};
CatalogItem _toCatalogItem(TraktCatalogMedia m, MediaKind kind) => CatalogItem(
source: CatalogSourceId.trakt,
kind: kind,
title: m.title ?? '',
year: m.year,
overview: m.overview,
runtimeMinutes: m.runtime,
rating: m.rating,
votes: m.votes,
genres: m.genres,
certification: m.certification,
trailerUrl: m.trailer,
airStatus: airStatusFor(m.status),
episodeCount: m.airedEpisodes,
network: m.network,
ids: CatalogItemIds(trakt: m.ids.trakt, slug: m.ids.slug, imdb: m.ids.imdb, tmdb: m.ids.tmdb, tvdb: m.ids.tvdb),
posterUrl: m.images?.primaryPoster,
backdropUrl: m.images?.primaryBackdrop,
);
CatalogItem _toCatalogItem(TraktCatalogMedia media, MediaKind kind, {int? watchers, DateTime? addedAt}) {
final audience = CatalogAudience(watchingNow: watchers, comments: media.commentCount);
final broadcast = CatalogBroadcast(
weekday: weekdayFor(media.airs?.day),
time: _broadcastTime(media.airs?.time),
timezone: media.airs?.timezone,
);
return CatalogItem(
source: CatalogSourceId.trakt,
kind: kind,
title: media.title ?? '',
year: media.year,
overview: media.overview,
runtimeMinutes: media.runtime,
rating: media.rating,
votes: media.votes,
genres: media.genres,
certification: media.certification,
trailerUrl: media.trailer,
airStatus: airStatusFor(media.status),
episodeCount: media.airedEpisodes,
network: media.network,
ids: CatalogItemIds(
trakt: media.ids.trakt,
slug: media.ids.slug,
imdb: media.ids.imdb,
tmdb: media.ids.tmdb,
tvdb: media.ids.tvdb,
),
posterUrl: media.images?.primaryPoster,
backdropUrl: media.images?.primaryBackdrop,
logoUrl: media.images?.primaryLogo,
audience: audience.isEmpty ? null : audience,
broadcast: broadcast.isEmpty ? null : broadcast,
releaseDate: _parseDate(media.released ?? media.firstAired),
addedAt: addedAt,
originalTitle: media.originalTitle,
tagline: media.tagline,
countries: media.country == null || media.country!.isEmpty ? null : [CountryCodes.normalizeCode(media.country!)],
languages: _languagesFor(media),
recommenders: _recommendersFor(media),
);
}
static int? weekdayFor(String? day) => switch (day?.toLowerCase()) {
'monday' => DateTime.monday,
'tuesday' => DateTime.tuesday,
'wednesday' => DateTime.wednesday,
'thursday' => DateTime.thursday,
'friday' => DateTime.friday,
'saturday' => DateTime.saturday,
'sunday' => DateTime.sunday,
_ => null,
};
static String? _broadcastTime(String? time) {
if (time == null || time.length < 5 || time[2] != ':') return null;
final hour = int.tryParse(time.substring(0, 2));
final minute = int.tryParse(time.substring(3, 5));
if (hour == null || hour < 0 || hour > 23 || minute == null || minute < 0 || minute > 59) {
return null;
}
return time.substring(0, 5);
}
static DateTime? _parseDate(String? raw) => raw == null ? null : DateTime.tryParse(raw);
static List<String>? _languagesFor(TraktCatalogMedia media) {
final languages = <String>{
if (media.language case final String language when language.isNotEmpty) language.toLowerCase(),
...?media.languages?.where((language) => language.isNotEmpty).map((language) => language.toLowerCase()),
...?media.availableTranslations
?.where((language) => language.isNotEmpty)
.map((language) => language.toLowerCase()),
};
return languages.isEmpty ? null : languages.toList(growable: false);
}
static List<CatalogRecommender>? _recommendersFor(TraktCatalogMedia media) {
final recommenders = <CatalogRecommender>[
for (final user in media.favoritedBy ?? const <TraktRecommendationUser>[])
if (user.username case final String username when username.isNotEmpty)
CatalogRecommender(
username: username,
name: user.name,
note: user.notes,
reason: CatalogRecommendationReason.favorited,
),
for (final user in media.recommendedBy ?? const <TraktRecommendationUser>[])
if (user.username case final String username when username.isNotEmpty)
CatalogRecommender(
username: username,
name: user.name,
note: user.notes,
reason: CatalogRecommendationReason.recommended,
),
];
return recommenders.isEmpty ? null : recommenders;
}
@override
void dispose() {
+57 -17
View File
@@ -29,17 +29,34 @@ class PlexDiscoverSession {
class PlexDiscoverPage {
final List<Map<String, dynamic>> items;
final bool hasMore;
final int? totalResults;
const PlexDiscoverPage({required this.items, this.hasMore = false});
const PlexDiscoverPage({required this.items, this.hasMore = false, this.totalResults});
}
class PlexDiscoverSearchResult {
final Map<String, dynamic> metadata;
final double? score;
const PlexDiscoverSearchResult({required this.metadata, this.score});
}
class PlexDiscoverHub {
final String id;
final String key;
final String title;
final String? type;
final String? style;
final PlexDiscoverPage page;
const PlexDiscoverHub({required this.id, required this.key, required this.title, required this.page});
const PlexDiscoverHub({
required this.id,
required this.key,
required this.title,
required this.page,
this.type,
this.style,
});
}
class PlexDiscoverException implements Exception {
@@ -79,8 +96,9 @@ class PlexDiscoverClient {
);
final container = _mediaContainer(data!);
final items = flexibleMapList(container['Metadata']);
final total = flexibleInt(container['totalSize']) ?? flexibleInt(container['size']) ?? items.length;
return PlexDiscoverPage(items: items, hasMore: offset + items.length < total);
final reportedTotal = flexibleInt(container['totalSize']);
final total = reportedTotal ?? flexibleInt(container['size']) ?? items.length;
return PlexDiscoverPage(items: items, hasMore: offset + items.length < total, totalResults: reportedTotal);
}
/// Discover's Home shelves — the rows Plex's own web client renders on its
@@ -93,19 +111,24 @@ class PlexDiscoverClient {
/// produce a catalog item are dropped before spending that request:
/// `directory` shelves list browse categories (genre/decade/award) and
/// `clip` shelves list trailers.
Future<List<PlexDiscoverHub>> getHomeHubs({int limit = 25, int concurrency = 6}) async {
Future<List<PlexDiscoverHub>> getHomeHubs({
int limit = 25,
int concurrency = 6,
bool includeImageVariants = false,
}) async {
final safeLimit = limit.clamp(1, 100);
final data = await _request('GET', '/hubs/sections/home', query: {'includeMeta': 1});
final container = _mediaContainer(data!);
final candidates = <({String id, String key, String title})>[];
final candidates = <({String id, String key, String title, String? type, String? style})>[];
final seen = <String>{};
for (final hub in flexibleMapList(container['Hub'])) {
if (_unrenderableHubTypes.contains(hub['type'])) continue;
final type = _nonEmptyString(hub['type']);
if (_unrenderableHubTypes.contains(type)) continue;
final key = _nonEmptyString(hub['key'] ?? hub['hubKey']);
final id = _nonEmptyString(hub['hubIdentifier']) ?? key;
final title = _nonEmptyString(hub['title']);
if (key == null || id == null || title == null || !seen.add(id)) continue;
candidates.add((id: id, key: key, title: title));
candidates.add((id: id, key: key, title: title, type: type, style: _nonEmptyString(hub['style'])));
}
if (candidates.isEmpty) return const [];
@@ -123,7 +146,7 @@ class PlexDiscoverClient {
final candidate = candidates[index];
try {
// One over the shelf size is what tells View All there is more.
pages[index] = await getHub(candidate.key, limit: safeLimit + 1);
pages[index] = await getHub(candidate.key, limit: safeLimit + 1, includeImageVariants: includeImageVariants);
} catch (error, stackTrace) {
firstError ??= error;
firstStackTrace ??= stackTrace;
@@ -144,7 +167,13 @@ class PlexDiscoverClient {
id: candidates[i].id,
key: candidates[i].key,
title: candidates[i].title,
page: PlexDiscoverPage(items: page.items.take(safeLimit).toList(), hasMore: page.items.length > safeLimit),
type: candidates[i].type,
style: candidates[i].style,
page: PlexDiscoverPage(
items: page.items.take(safeLimit).toList(),
hasMore: page.items.length > safeLimit || (page.totalResults != null && page.totalResults! > safeLimit),
totalResults: page.totalResults,
),
),
];
}
@@ -152,23 +181,33 @@ class PlexDiscoverClient {
/// One Discover hub in full. The provider ignores container offsets on hub
/// keys and truncates with `limit` instead, so a hub is always a single
/// page and [PlexDiscoverPage.hasMore] never reports one.
Future<PlexDiscoverPage> getHub(String key, {int limit = 100}) async {
Future<PlexDiscoverPage> getHub(String key, {int limit = 100, bool includeImageVariants = false}) async {
final safeLimit = limit.clamp(1, 500);
final data = await _request(
'GET',
key,
// Streaming parts and image variants roughly double the payload of a
// 25-item shelf and nothing in the catalog layer reads them.
query: {'limit': safeLimit, 'includeMeta': 1, 'excludeElements': 'Media,Image'},
query: {
'limit': safeLimit,
'includeMeta': 1,
'includeUserState': 1,
// Allowing Image on the measured 26-item hub grew 27,287 -> 55,925
// bytes (+104.95%). Discover is uncached and Home hydrates up to six
// hubs concurrently, so spotlight/logo consumers must opt in per
// request and ordinary shelves stay narrow.
'excludeElements': includeImageVariants ? 'Media' : 'Media,Image',
},
);
final container = _mediaContainer(data!);
final hub = firstFlexibleMap(container['Hub']);
final containerItems = flexibleMapList(container['Metadata']);
final rawItems = containerItems.isNotEmpty ? containerItems : flexibleMapList(hub?['Metadata']);
return PlexDiscoverPage(items: rawItems.take(safeLimit).toList());
return PlexDiscoverPage(
items: rawItems.take(safeLimit).toList(),
totalResults: flexibleInt(container['totalSize']),
);
}
Future<List<Map<String, dynamic>>> search(String query, {int limit = 30}) async {
Future<List<PlexDiscoverSearchResult>> search(String query, {int limit = 30}) async {
final data = await _request(
'GET',
'/library/search',
@@ -185,7 +224,8 @@ class PlexDiscoverClient {
return [
for (final group in flexibleMapList(container['SearchResults']))
for (final result in flexibleMapList(group['SearchResult']))
if (firstFlexibleMap(result['Metadata']) case final Map<String, dynamic> metadata) metadata,
if (firstFlexibleMap(result['Metadata']) case final Map<String, dynamic> metadata)
PlexDiscoverSearchResult(metadata: metadata, score: flexibleDouble(result['score'])),
];
}
+7 -3
View File
@@ -3,6 +3,9 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../i18n/app_locale_utils.dart';
import '../../i18n/strings.g.dart';
import '../../models/seerr/seerr_details.dart';
import '../../models/seerr/seerr_media.dart';
import '../../models/seerr/seerr_page.dart';
@@ -103,7 +106,7 @@ class SeerrClient {
/// `/search` — Seerr's TMDB-backed catalog search (mixed results, person
/// entries dropped).
Future<SeerrPage<SeerrMedia>> search(String query, {int page = 1}) async {
final data = await _request('GET', '/search', query: {'query': query, 'page': page});
final data = await _request('GET', '/search', query: {'query': query, 'page': page, 'language': _language});
return _parseMediaPage(data, null);
}
@@ -116,7 +119,7 @@ class SeerrClient {
_mediaPage('/tv/$tmdbId/recommendations', page, 'tv');
Future<SeerrPage<SeerrMedia>> _mediaPage(String path, int page, String? coerceMediaType) async {
final data = await _request('GET', path, query: {'page': page});
final data = await _request('GET', path, query: {'page': page, 'language': _language});
return _parseMediaPage(data, coerceMediaType);
}
@@ -135,7 +138,7 @@ class SeerrClient {
Future<SeerrDetails> getTv(int tmdbId) => _details('/tv/$tmdbId');
Future<SeerrDetails> _details(String path) async {
final data = await _request('GET', path);
final data = await _request('GET', path, query: {'language': _language});
return SeerrDetails.fromJson(data as Map<String, dynamic>);
}
@@ -171,6 +174,7 @@ class SeerrClient {
}
// ---------- Internals ----------
String get _language => LocaleSettings.currentLocale.plexLanguageCode;
Future<dynamic> _request(
String method,
+244 -59
View File
@@ -13,7 +13,14 @@ import '../tracker_http_client.dart';
import '../tracker_session.dart';
import 'anilist_constants.dart';
typedef AnilistCharacter = ({String name, String? role, String? imageUrl});
typedef AnilistRelationEdge = ({String? relationType, AnilistMedia item});
typedef AnilistDetailResponse = ({
AnilistMedia? item,
List<AnilistCharacter> characters,
List<AnilistMedia> recommendations,
List<AnilistRelationEdge> relations,
});
/// GraphQL client for AniList.
///
@@ -21,6 +28,124 @@ typedef AnilistCharacter = ({String name, String? role, String? imageUrl});
/// [onSessionInvalidated] clears it so the user re-auths.
class AnilistClient implements DisposableTrackerClient {
static const String catalogMediaFields = '''
id
idMal
title {
english
romaji
native
userPreferred
}
synonyms
format
status
episodes
duration
description
averageScore
meanScore
popularity
favourites
trending
season
seasonYear
startDate {
year
month
day
}
endDate {
year
month
day
}
genres
isAdult
source
countryOfOrigin
coverImage {
extraLarge
large
color
}
bannerImage
studios(isMain: true) {
nodes {
name
}
}
trailer {
id
site
}
nextAiringEpisode {
episode
airingAt
timeUntilAiring
}
rankings {
rank
type
format
year
season
allTime
context
}
''';
/// Public unauthenticated A/B on 2026-07-29 used the current trending
/// Page query at its real 25-item row size, with identical media ids:
/// 65,586 B without cast and 104,260 B with this six-character connection.
/// That is +38,674 B (+58.97%, 1.59x total) in uncompressed response bytes.
static const String catalogRowCharacterFields =
'''
characters(
page: 1
perPage: ${AnilistConstants.catalogRowCastLimit}
sort: [ROLE, RELEVANCE]
) {
edges {
role
node {
name {
full
}
image {
large
medium
}
}
}
}
''';
static const String catalogRowMediaFields =
'''
$catalogMediaFields
$catalogRowCharacterFields
''';
static const String catalogDetailCharacterFields = '''
characters(page: 1, perPage: \$castPerPage, sort: [ROLE, RELEVANCE]) {
edges {
role
node {
name {
full
}
image {
large
medium
}
}
}
}
''';
/// Recommendation and relation cards keep the previous compact media shape:
/// recursively selecting every row/detail field would multiply nested data.
static const String catalogRelatedMediaFields = '''
id
idMal
title {
@@ -59,6 +184,37 @@ class AnilistClient implements DisposableTrackerClient {
}
''';
/// AniList does not offer tag pagination. Keep its selection leaf-only and
/// retain at most 20 tags in [AnilistMedia]; the variable response array is
/// accepted only on the one-item detail path, never across a 25-50 item row.
static const String catalogDetailMediaFields = '''
tags {
name
rank
isMediaSpoiler
}
externalLinks {
site
url
}
streamingEpisodes {
title
thumbnail
url
site
}
staff(page: 1, perPage: \$staffPerPage) {
edges {
role
node {
name {
full
}
}
}
}
''';
final TrackerSession _session;
final TrackerHttpClient _http;
final void Function() onSessionInvalidated;
@@ -167,16 +323,22 @@ class AnilistClient implements DisposableTrackerClient {
}
Future<AnilistPage> getTrendingAnime({int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'TRENDING_DESC', page: page, limit: limit);
_getAnimePage(sort: 'TRENDING_DESC', page: page, limit: limit, includeRowCast: true);
Future<AnilistPage> getPopularAnime({int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'POPULARITY_DESC', page: page, limit: limit);
_getAnimePage(sort: 'POPULARITY_DESC', page: page, limit: limit, includeRowCast: true);
Future<AnilistPage> getSeasonalAnime(String season, int seasonYear, {int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'POPULARITY_DESC', season: season, seasonYear: seasonYear, page: page, limit: limit);
Future<AnilistPage> getSeasonalAnime(String season, int seasonYear, {int page = 1, int limit = 25}) => _getAnimePage(
sort: 'POPULARITY_DESC',
season: season,
seasonYear: seasonYear,
page: page,
limit: limit,
includeRowCast: true,
);
Future<AnilistPage> searchAnime(String search, {int page = 1, int limit = 30}) =>
_getAnimePage(sort: 'SEARCH_MATCH', search: search, page: page, limit: limit);
_getAnimePage(sort: 'SEARCH_MATCH', search: search, page: page, limit: limit, includeRowCast: false);
Future<AnilistPage> _getAnimePage({
required String sort,
@@ -185,7 +347,9 @@ class AnilistClient implements DisposableTrackerClient {
int? seasonYear,
required int page,
required int limit,
required bool includeRowCast,
}) async {
final fields = includeRowCast ? catalogRowMediaFields : catalogMediaFields;
final mediaQuery =
'''
query(
@@ -208,7 +372,7 @@ class AnilistClient implements DisposableTrackerClient {
season: \$season
seasonYear: \$seasonYear
) {
$catalogMediaFields
$fields
}
}
}
@@ -250,7 +414,7 @@ class AnilistClient implements DisposableTrackerClient {
required int perChunk,
required bool idsOnly,
}) async {
final fields = idsOnly ? 'id idMal' : catalogMediaFields;
final fields = idsOnly ? 'id idMal' : catalogRowMediaFields;
final listQuery =
'''
query(\$userId: Int!, \$chunk: Int, \$perChunk: Int) {
@@ -323,68 +487,73 @@ class AnilistClient implements DisposableTrackerClient {
await query(mutation, variables: {'mediaId': mediaId, 'status': status});
}
Future<List<AnilistCharacter>> getAnimeCharacters(int id, {int limit = 20}) async {
const characterQuery = '''
query(\$id: Int!, \$perPage: Int) {
Media(id: \$id, type: ANIME) {
characters(page: 1, perPage: \$perPage) {
edges {
role
node {
name {
full
}
image {
large
medium
}
}
}
}
}
}
''';
final data = await query(characterQuery, variables: {'id': id, 'perPage': limit.clamp(1, 50).toInt()});
final media = data['Media'];
final characters = media is Map ? media['characters'] : null;
final edges = characters is Map ? characters['edges'] : null;
if (edges is! List) return const [];
return [
for (final edge in edges)
if (edge is Map)
if (edge['node'] case final Map node)
if (node['name'] case final Map name)
if (name['full'] case final String full)
if (full.isNotEmpty)
(
name: full,
role: edge['role'] as String?,
imageUrl: node['image'] is Map
? ((node['image'] as Map)['large'] as String? ?? (node['image'] as Map)['medium'] as String?)
: null,
),
];
}
Future<List<AnilistMedia>> getAnimeRecommendations(int id, {int limit = 20}) async {
final recommendationQuery =
Future<AnilistDetailResponse> getAnimeDetail(
int id, {
int castLimit = 20,
int relatedLimit = 20,
bool includeCharacters = true,
}) async {
final castVariable = includeCharacters ? r'$castPerPage: Int' : '';
final characterFields = includeCharacters ? catalogDetailCharacterFields : '';
final detailQuery =
'''
query(\$id: Int!, \$perPage: Int) {
query(
\$id: Int!
$castVariable
\$relatedPerPage: Int
\$staffPerPage: Int
) {
Media(id: \$id, type: ANIME) {
recommendations(page: 1, perPage: \$perPage) {
$catalogMediaFields
$catalogDetailMediaFields
$characterFields
recommendations(page: 1, perPage: \$relatedPerPage) {
nodes {
mediaRecommendation {
$catalogMediaFields
$catalogRelatedMediaFields
}
}
}
relations(page: 1, perPage: \$relatedPerPage) {
edges {
relationType(version: 2)
node {
$catalogRelatedMediaFields
}
}
}
}
}
''';
final data = await query(recommendationQuery, variables: {'id': id, 'perPage': limit.clamp(1, 50).toInt()});
final data = await query(
detailQuery,
variables: {
'id': id,
if (includeCharacters) 'castPerPage': castLimit.clamp(1, 50).toInt(),
'relatedPerPage': relatedLimit.clamp(1, 50).toInt(),
'staffPerPage': 8,
},
);
final media = data['Media'];
final recommendations = media is Map ? media['recommendations'] : null;
final nodes = recommendations is Map ? recommendations['nodes'] : null;
if (media is! Map<String, dynamic>) {
return (
item: null,
characters: const <AnilistCharacter>[],
recommendations: const <AnilistMedia>[],
relations: const <AnilistRelationEdge>[],
);
}
final item = AnilistMedia.fromJson(media);
return (
item: item,
characters: item.characters ?? const <AnilistCharacter>[],
recommendations: _recommendationsFrom(media['recommendations']),
relations: _relationsFrom(media['relations']),
);
}
static List<AnilistMedia> _recommendationsFrom(Object? value) {
final nodes = value is Map ? value['nodes'] : null;
if (nodes is! List) return const [];
final items = <AnilistMedia>[];
for (final node in nodes) {
@@ -397,6 +566,22 @@ class AnilistClient implements DisposableTrackerClient {
return items;
}
static List<AnilistRelationEdge> _relationsFrom(Object? value) {
final edges = value is Map ? value['edges'] : null;
if (edges is! List) return const [];
final relations = <AnilistRelationEdge>[];
for (final edge in edges) {
if (edge is! Map) continue;
final node = edge['node'];
if (node is! Map<String, dynamic>) continue;
final item = AnilistMedia.fromJson(node);
if (!item.isAdult) {
relations.add((relationType: edge['relationType'] as String?, item: item));
}
}
return relations;
}
Future<Map<String, dynamic>> query(String query, {Map<String, dynamic>? variables}) async {
final uri = Uri.parse(AnilistConstants.apiBase);
final headers = AnilistConstants.headers(accessToken: _session.accessToken);
@@ -7,6 +7,7 @@ class AnilistConstants {
AnilistConstants._();
static const String apiBase = 'https://graphql.anilist.co';
static const int catalogRowCastLimit = 6;
static Map<String, String> headers({String? accessToken}) => {
'Content-Type': 'application/json',
+18 -16
View File
@@ -80,11 +80,21 @@ class MalClient implements DisposableTrackerClient {
}
}
/// Anime summary fields the Explore catalog requests on list endpoints.
/// Anime summary fields shared by every Explore list and nested detail node.
///
/// The compact metadata widening was measured at +9.2% for 25 items
/// (45,895 -> 50,111 bytes) and adds no request.
static const String catalogFields =
'id,title,main_picture,alternative_titles,start_date,synopsis,mean,'
'genres,media_type,rating,num_episodes,average_episode_duration,start_season,'
'status,studios,num_scoring_users';
'status,studios,num_scoring_users,broadcast,popularity,num_list_users,rank,'
'nsfw,source,end_date';
/// The existing detail request widened by +22.3% in the measured sample
/// (18,624 -> 22,772 bytes), still with no additional round trip.
static const String detailFields =
'$catalogFields,recommendations%7B$catalogFields%7D,'
'related_anime%7B$catalogFields%7D,statistics,pictures,background';
static const String _characterFields = 'role,main_picture,first_name,last_name';
@@ -115,20 +125,12 @@ class MalClient implements DisposableTrackerClient {
Future<MalPage<MalAnime>> searchAnime(String query, {int page = 1, int limit = 30}) =>
_getAnimePage('/anime', {'q': query}, page: page, limit: limit);
/// Community "users also liked" titles from the anime detail's
/// `recommendations` field, with the catalog fields selected on the nested
/// nodes (`fields=recommendations{...}` — braces percent-encoded, MAL
/// accepts the nested selector).
Future<List<MalAnime>> getAnimeRecommendations(int animeId, {int limit = 20}) async {
final res = await _request('GET', '/anime/$animeId?fields=recommendations%7B$catalogFields%7D');
if (res is! Map) return const [];
final recommendations = res['recommendations'];
if (recommendations is! List) return const [];
return [
for (final entry in recommendations.take(limit))
if (entry is Map<String, dynamic> && entry['node'] is Map<String, dynamic>)
MalAnime.fromJson(entry['node'] as Map<String, dynamic>),
];
/// The anime detail body, including its community recommendations,
/// franchise relations and status statistics.
Future<MalAnimeDetail?> getAnimeDetail(int animeId, {int relatedLimit = 20}) async {
final res = await _request('GET', '/anime/$animeId?fields=$detailFields');
if (res is! Map) return null;
return MalAnimeDetail.fromJson(res.cast<String, dynamic>(), relatedLimit: relatedLimit);
}
Future<MalPage<MalAnime>> _getAnimePage(
+25 -4
View File
@@ -6,17 +6,28 @@ class MalPage<T> {
final List<T> items;
final bool hasMore;
const MalPage({required this.items, this.hasMore = false});
/// Entry-level `/anime/ranking` positions, aligned with [items].
final List<int?> rankingRanks;
const MalPage({required this.items, this.hasMore = false, this.rankingRanks = const []});
static MalPage<T> fromJson<T>(Object? json, T Function(Map<String, dynamic> node) fromNode) {
if (json is! Map) return MalPage(items: List<T>.empty());
final data = json['data'];
final items = <T>[
final entries = [
if (data is List)
for (final entry in data)
if (entry is Map && entry['node'] is Map) fromNode((entry['node'] as Map).cast<String, dynamic>()),
if (entry is Map && entry['node'] is Map) entry,
];
return MalPage(items: items, hasMore: _hasNext(json));
final items = [for (final entry in entries) fromNode((entry['node'] as Map).cast<String, dynamic>())];
final rankingRanks = [
for (final entry in entries)
switch (entry['ranking']) {
final Map ranking => _positiveInt(ranking['rank']),
_ => null,
},
];
return MalPage(items: items, hasMore: _hasNext(json), rankingRanks: rankingRanks);
}
/// Like [fromJson] but hands the parser the whole `data[]` entry, for
@@ -33,6 +44,16 @@ class MalPage<T> {
return MalPage(items: items, hasMore: _hasNext(json));
}
static int? _positiveInt(Object? value) {
final parsed = switch (value) {
final int number => number,
final num number => number.toInt(),
final String text => int.tryParse(text),
_ => null,
};
return parsed != null && parsed > 0 ? parsed : null;
}
static bool _hasNext(Map<dynamic, dynamic> json) {
final paging = json['paging'];
final next = paging is Map ? paging['next'] : null;
@@ -4,7 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../models/simkl/simkl_all_items_entry.dart';
import '../../../models/simkl/simkl_best_item.dart';
import '../../../models/simkl/simkl_recommendation.dart';
import '../../../models/simkl/simkl_detail.dart';
import '../../../models/simkl/simkl_search_result.dart';
import '../../../models/simkl/simkl_trending_item.dart';
import '../../trakt/trakt_page.dart';
@@ -113,15 +113,9 @@ class SimklClient implements DisposableTrackerClient {
await _request('POST', '/sync/add-to-list', body: body);
}
Future<List<SimklRecommendation>> getRecommendations(SimklCatalogType urlType, int simklId) async {
Future<SimklDetail?> getDetail(SimklCatalogType urlType, int simklId) async {
final decoded = await _request('GET', '/${urlType.detailPath}/$simklId');
if (decoded is! Map) return const [];
final recommendations = decoded['users_recommendations'];
if (recommendations is! List) return const [];
return [
for (final item in recommendations)
if (item is Map<String, dynamic>) SimklRecommendation.fromJson(item),
];
return decoded is Map<String, dynamic> ? SimklDetail.fromJson(decoded) : null;
}
Future<dynamic> _request(
+26 -8
View File
@@ -150,15 +150,20 @@ class TraktClient implements DisposableTrackerClient {
return _decodeMedia(res.body);
}
/// Cast credits of a title (`GET /{movies|shows}/{id}/people`), in billing
/// order. Crew is not parsed. [id] is a Trakt numeric id or slug.
Future<List<TraktCastEntry>> getPeople(TraktCatalogType type, String id) async {
final res = await _requestResponse('GET', '/${type.name}/$id/people?$_catalogExtended');
/// People for a title (`GET /{movies|shows}/{id}/people`). Show requests
/// include `guest_stars`; Trakt documents that this returns every guest star
/// with at least one episode and can be a lot of data, so callers must bound
/// the cast they retain.
Future<TraktPeople> getPeople(TraktCatalogType type, String id) async {
final extended = type == TraktCatalogType.shows ? '$_catalogExtended,guest_stars' : _catalogExtended;
final res = await _requestResponse('GET', '/${type.name}/$id/people?$extended');
final decoded = TrackerHttpClient.decodeJson(res.body);
if (decoded is! Map) return const [];
final cast = decoded['cast'];
if (cast is! List) return const [];
return [for (final e in cast.whereType<Map<String, dynamic>>()) TraktCastEntry.fromJson(e)];
if (decoded is! Map) return const TraktPeople();
return TraktPeople(
cast: _decodePeopleEntries(decoded['cast']),
guestStars: _decodePeopleEntries(decoded['guest_stars']),
crew: _decodeCrewEntries(decoded['crew']),
);
}
/// Body shape: `{"movies":[{"ids":{...}}],"shows":[{"ids":{...}}]}`.
@@ -167,6 +172,19 @@ class TraktClient implements DisposableTrackerClient {
Future<void> removeFromWatchlist(Map<String, dynamic> body) => _request('POST', '/sync/watchlist/remove', body: body);
static List<TraktCastEntry> _decodePeopleEntries(Object? raw) {
if (raw is! List) return const [];
return [for (final entry in raw.whereType<Map<String, dynamic>>()) TraktCastEntry.fromJson(entry)];
}
static List<TraktCastEntry> _decodeCrewEntries(Object? raw) {
if (raw is! Map) return const [];
return [
for (final department in raw.values.whereType<List>())
for (final entry in department.whereType<Map<String, dynamic>>()) TraktCastEntry.fromJson(entry),
];
}
static List<TraktCatalogEntry> _decodeEntries(String body) {
final decoded = TrackerHttpClient.decodeJson(body);
if (decoded is! List) return const [];
+2 -2
View File
@@ -6,7 +6,7 @@ class TraktPage<T> {
final List<T> items;
final int page;
final int pageCount;
final int itemCount;
final int? itemCount;
const TraktPage({required this.items, required this.page, required this.pageCount, required this.itemCount});
@@ -18,6 +18,6 @@ class TraktPage<T> {
items: items,
page: int.tryParse(res.headers['x-pagination-page'] ?? '') ?? 1,
pageCount: int.tryParse(res.headers['x-pagination-page-count'] ?? '') ?? 1,
itemCount: int.tryParse(res.headers['x-pagination-item-count'] ?? '') ?? items.length,
itemCount: int.tryParse(res.headers['x-pagination-item-count'] ?? ''),
);
}