Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor took `rating` and fell back to `audienceRating` only when it was null, so a Plex movie carrying four attributed scores surfaced one, and which one was whatever the server happened to put in the scalar slot. #1755 asked for a setting to choose the source; showing all of them answers it without one. The data was already on the wire and being thrown away. `/library/metadata/ {id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels, TMDB — with no extra query parameter, but PlexMetadataDto declared no field for it, so json_serializable dropped the key. The identical parse already existed in plex_catalog_source for the Explore tab and had simply never been wired to library items. Model the scores as a list rather than widening the scalar pair. The neutral MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage and audienceRatingImage, which the list subsumes — Plex sends those images on listings too, so the same field covers both response shapes and no caller narrows to a backend type to read a score any more. CatalogRatingSource is promoted to lib/media as MediaRatingSource instead of growing a second near-identical type beside it, and plex_catalog_source's _ratingsFor becomes the shared plexRatingSources so one implementation serves both paths. There is no persistence to migrate: MediaItem.toJson has no production caller, the offline path re-parses raw Plex JSON through the same mapper, and Plex's audienceRating sort is server-supplied data, not a model read. Cards and the dashboard still show fewer scores than detail screens, and that part is a real Plex limit rather than a shortcut. Section listings send only the scalar pair; includeRatings, includeElements=Rating, includeFields=Rating, includeChildren and includeExtras were each probed against a live server and none surfaced the array, while includeGuids=1 demonstrably does add Guid[] — the probe works, the parameter does not exist. Hydrating every card would be one request per row, so listings render whatever their own response carried, which is one or two attributed scores rather than the single one they showed before. Jellyfin has no per-source array at all: the server collapses whatever its fetchers found into CommunityRating and CriticRating. CommunityRating's provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or a local NFO, last writer wins — so it stays the generic `audience` source with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a 0-100 percent and is divided by ten explicitly rather than folded by magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin reuses CommunityRating for the EXIF 0-5 star. The badges share one slot on every surface. On the phone hero the scores go in a single pill because that chip row is a height-clipped Wrap and a chip per source would push year, certification and runtime out of the visible band on short heroes; on the TV detail line and the dashboard spotlight the group occupies the one metadata slot so bullet separators do not multiply. The group announces itself as a single semantics node naming each source, because a bare row of four percentages tells a screen reader nothing about which score is which. rating_utils drops parseRatingImage and isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper — and the source-key resolver and label map, previously private to the Explore detail screen, become the shared pair both screens use. The label strings move from explore.ratingSource to common.ratingSource accordingly, which costs no translations because every non-English value was empty; running clean_translations also scaffolds startup.quitPlezy and startup.restartRequiredBody, which were already drifted. Verified against the live server the probes came from: a detail response now yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the production mapper and badge resolver, and the listing response for the same title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures. Coverage adds mapper ordering, dedupe against the array's repeat of the scalar, out-of-range rejection, the Jellyfin scale and photo guard, the CatalogItem conversion that feeds Explore's dashboard hubs, and the three render surfaces including the semantics announcement. close #1755
630 lines
25 KiB
Dart
630 lines
25 KiB
Dart
import '../../media/media_rating.dart';
|
|
import '../../media/media_kind.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/future_coalescer.dart';
|
|
import '../../utils/trailer_urls.dart';
|
|
import 'catalog_source.dart';
|
|
import 'catalog_watchlist_machinery.dart';
|
|
|
|
/// [CatalogSource] backed by Simkl's REST API and public discovery CDN.
|
|
///
|
|
/// The client is owned and rebound by `TrackersProvider`; this source only
|
|
/// borrows it. Simkl accepts the media server's native external IDs directly,
|
|
/// so library-item resolution does not require Fribb.
|
|
class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
|
|
static const Duration _rowCacheTtl = Duration(minutes: 15);
|
|
|
|
final SimklClient _client;
|
|
final Map<CatalogRowId, List<CatalogItem>> _rowCache = {};
|
|
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;
|
|
|
|
SimklCatalogSource(this._client);
|
|
|
|
@override
|
|
CatalogSourceId get id => CatalogSourceId.simkl;
|
|
|
|
@override
|
|
String get displayName => 'Simkl';
|
|
|
|
@override
|
|
List<CatalogRowId> get supportedRows => const [
|
|
CatalogRowId.watchlist,
|
|
CatalogRowId.trendingMovies,
|
|
CatalogRowId.trendingShows,
|
|
CatalogRowId.trendingAnime,
|
|
CatalogRowId.popularShows,
|
|
CatalogRowId.popularAnime,
|
|
];
|
|
|
|
@override
|
|
bool get supportsWatchlist => true;
|
|
|
|
@override
|
|
String get watchlistLogLabel => 'Simkl: Plan to Watch';
|
|
|
|
/// Simkl's plan-to-watch snapshot is one unpaginated response.
|
|
@override
|
|
int get watchlistPageLimit => 1;
|
|
|
|
@override
|
|
int get watchlistMaxPages => 1;
|
|
|
|
@override
|
|
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
|
|
if (!supportedRows.contains(row)) {
|
|
throw ArgumentError('Simkl does not serve ${row.name}');
|
|
}
|
|
|
|
final normalizedPage = page < 1 ? 1 : page;
|
|
final normalizedLimit = limit < 1 ? 1 : limit;
|
|
var cached = _rowCache[row];
|
|
final loadedAt = _rowCacheLoadedAt[row];
|
|
final expired = normalizedPage == 1 && loadedAt != null && DateTime.now().difference(loadedAt) >= _rowCacheTtl;
|
|
if (cached == null || expired) {
|
|
cached = await _rowLoads.run(row, () async {
|
|
final current = _rowCache[row];
|
|
final currentLoadedAt = _rowCacheLoadedAt[row];
|
|
final currentExpired =
|
|
normalizedPage == 1 &&
|
|
currentLoadedAt != null &&
|
|
DateTime.now().difference(currentLoadedAt) >= _rowCacheTtl;
|
|
if (current != null && !currentExpired) return current;
|
|
if (row == CatalogRowId.watchlist && currentExpired) _invalidateWatchlistCache();
|
|
final fetched = await _fetchUnpaginatedRow(row);
|
|
_rowCache[row] = fetched;
|
|
_rowCacheLoadedAt[row] = DateTime.now();
|
|
return fetched;
|
|
});
|
|
}
|
|
|
|
final start = (normalizedPage - 1) * normalizedLimit;
|
|
if (start >= cached.length) return const CatalogPage(items: []);
|
|
final end = (start + normalizedLimit).clamp(0, cached.length);
|
|
return CatalogPage(items: cached.sublist(start, end), hasMore: end < cached.length);
|
|
}
|
|
|
|
Future<List<CatalogItem>> _fetchUnpaginatedRow(CatalogRowId row) async => switch (row) {
|
|
CatalogRowId.watchlist => _fetchWatchlistRow(),
|
|
// Simkl requires visible attribution wherever the CDN trending data is
|
|
// displayed. The source switcher logo/name supplies that attribution.
|
|
CatalogRowId.trendingMovies => _fetchTrending(SimklCatalogType.movies),
|
|
CatalogRowId.trendingShows => _fetchTrending(SimklCatalogType.tv),
|
|
CatalogRowId.trendingAnime => _fetchTrending(SimklCatalogType.anime),
|
|
CatalogRowId.popularShows => _fetchBest(SimklCatalogType.tv),
|
|
CatalogRowId.popularAnime => _fetchBest(SimklCatalogType.anime),
|
|
CatalogRowId.recommendedMovies ||
|
|
CatalogRowId.recommendedShows ||
|
|
CatalogRowId.popularMovies ||
|
|
CatalogRowId.suggestedAnime ||
|
|
CatalogRowId.airingAnime ||
|
|
CatalogRowId.trending ||
|
|
CatalogRowId.upcomingMovies ||
|
|
CatalogRowId.upcomingShows => throw ArgumentError('Simkl does not serve ${row.name}'),
|
|
};
|
|
|
|
Future<List<CatalogItem>> _fetchTrending(SimklCatalogType type) async => [
|
|
for (final item in await _client.getTrending(type))
|
|
if (item.ids.hasAny) _catalogItemFromTrending(item, type),
|
|
];
|
|
|
|
Future<List<CatalogItem>> _fetchBest(SimklCatalogType type) async => [
|
|
for (final item in await _client.getBest(type))
|
|
if (item.ids.hasAny) _catalogItemFromBest(item, type),
|
|
];
|
|
|
|
Future<List<CatalogItem>> _fetchWatchlistRow() async {
|
|
final response = await _getWatchlistItems();
|
|
return [
|
|
for (final entry in response.movies)
|
|
if (entry.media?.ids.hasAny == true) _catalogItemFromAllItems(entry, SimklCatalogType.movies),
|
|
for (final entry in response.shows)
|
|
if (entry.media?.ids.hasAny == true) _catalogItemFromAllItems(entry, SimklCatalogType.tv),
|
|
for (final entry in response.anime)
|
|
if (entry.media?.ids.hasAny == true) _catalogItemFromAllItems(entry, SimklCatalogType.anime),
|
|
];
|
|
}
|
|
|
|
Future<SimklAllItems> _getWatchlistItems() {
|
|
final cached = _watchlistCache;
|
|
if (cached != null) return Future.value(cached);
|
|
final generation = _watchlistCacheGeneration;
|
|
return _watchlistLoad.run(() async {
|
|
final response = await _client.getAllItems(extended: 'full');
|
|
if (generation == _watchlistCacheGeneration) _watchlistCache = response;
|
|
return response;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
|
|
final trimmed = query.trim();
|
|
if (trimmed.isEmpty || limit <= 0) return const [];
|
|
final perType = (limit / 3).ceil().clamp(1, 50);
|
|
final pages = await Future.wait([
|
|
_client.searchCatalog(SimklCatalogType.movies, trimmed, limit: perType),
|
|
_client.searchCatalog(SimklCatalogType.tv, trimmed, limit: perType),
|
|
_client.searchCatalog(SimklCatalogType.anime, trimmed, limit: perType),
|
|
]);
|
|
final types = const [SimklCatalogType.movies, SimklCatalogType.tv, SimklCatalogType.anime];
|
|
return [
|
|
for (var i = 0; i < pages.length; i++)
|
|
for (final item in pages[i].items)
|
|
if (item.ids.hasAny) _catalogItemFromSearch(item, types[i]),
|
|
].take(limit).toList();
|
|
}
|
|
|
|
static CatalogAirStatus? airStatusFor(String? status, MediaKind kind) => switch (status?.toLowerCase()) {
|
|
'airing' || 'ongoing' => CatalogAirStatus.airing,
|
|
'ended' => kind == MediaKind.movie ? null : CatalogAirStatus.ended,
|
|
'premiere' || 'tba' => CatalogAirStatus.upcoming,
|
|
_ => null,
|
|
};
|
|
|
|
static MediaKind _kindFor(SimklCatalogType type, {String? animeType}) {
|
|
if (type == SimklCatalogType.movies) return MediaKind.movie;
|
|
if (type == SimklCatalogType.anime && animeType?.toLowerCase() == 'movie') return MediaKind.movie;
|
|
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<MediaRatingSource>? _ratingsFor(SimklRatings? ratings) {
|
|
if (ratings == null) return null;
|
|
final result = <MediaRatingSource>[];
|
|
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(MediaRatingSource(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: title,
|
|
year: item.year,
|
|
overview: item.overview,
|
|
runtimeMinutes: item.runtimeMinutes,
|
|
rating: rating?.rating,
|
|
votes: rating?.votes,
|
|
genres: item.genres,
|
|
trailerUrl: item.trailerUrl,
|
|
airStatus: airStatusFor(item.status, kind),
|
|
episodeCount: kind == MediaKind.movie || (item.totalEpisodes ?? 0) <= 0 ? null : item.totalEpisodes,
|
|
network: item.network,
|
|
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: title,
|
|
year: item.year,
|
|
rating: rating?.rating,
|
|
votes: rating?.votes,
|
|
airStatus: airStatusFor(item.status, kind),
|
|
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(
|
|
source: CatalogSourceId.simkl,
|
|
kind: kind,
|
|
title: item.title ?? '',
|
|
year: item.year,
|
|
rating: rating?.rating,
|
|
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,
|
|
kind: kind,
|
|
title: media.title ?? '',
|
|
year: media.year,
|
|
overview: media.overview,
|
|
runtimeMinutes: media.runtime,
|
|
genres: media.genres,
|
|
airStatus: airStatusFor(media.status, kind),
|
|
episodeCount: kind == MediaKind.movie || (entry.totalEpisodes ?? 0) <= 0 ? null : entry.totalEpisodes,
|
|
network: media.network,
|
|
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 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: 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<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
|
|
final simklId = item.ids.simkl;
|
|
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
|
|
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
|
|
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
|
|
|
|
@override
|
|
Future<WatchlistKeyPage> fetchWatchlistKeyPage(int page, int limit) async {
|
|
final response = await _getWatchlistItems();
|
|
return (
|
|
groups: [
|
|
for (final entry in response.movies)
|
|
if (entry.media case final media?) membershipKeysFor(MediaKind.movie, media.ids.toCatalogItemIds()),
|
|
for (final entry in response.shows)
|
|
if (entry.media case final media?) membershipKeysFor(MediaKind.show, media.ids.toCatalogItemIds()),
|
|
for (final entry in response.anime)
|
|
if (entry.media case final media?)
|
|
[
|
|
...membershipKeysFor(MediaKind.movie, media.ids.toCatalogItemIds()),
|
|
...membershipKeysFor(MediaKind.show, media.ids.toCatalogItemIds()),
|
|
],
|
|
],
|
|
hasMore: false,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}) async {
|
|
final bucket = kind == MediaKind.show ? 'shows' : 'movies';
|
|
final mutationIds = SimklIds.fromCatalogItemIds(ids).toJson();
|
|
if (add) {
|
|
await _client.addToList({
|
|
bucket: [
|
|
{'to': 'plantowatch', 'ids': mutationIds},
|
|
],
|
|
});
|
|
} else {
|
|
// Simkl has no remove-from-list endpoint. A bare-IDs history removal drops
|
|
// the title from the user's library entirely; for a Plan to Watch entry,
|
|
// that is the documented and intended removal behavior.
|
|
await _client.removeFromHistory({
|
|
bucket: [
|
|
{'ids': mutationIds},
|
|
],
|
|
});
|
|
}
|
|
_invalidateWatchlistCache();
|
|
}
|
|
|
|
void _invalidateWatchlistCache() {
|
|
_watchlistCacheGeneration++;
|
|
_watchlistCache = null;
|
|
_watchlistLoad.reset();
|
|
_rowCache.remove(CatalogRowId.watchlist);
|
|
_rowCacheLoadedAt.remove(CatalogRowId.watchlist);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_invalidateWatchlistCache();
|
|
_rowCache.clear();
|
|
_rowCacheLoadedAt.clear();
|
|
_animeIds.clear();
|
|
disposeWatchlistMachinery();
|
|
}
|
|
}
|