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:
@@ -10,4 +10,20 @@ class CatalogCastMember {
|
||||
final String? imageUrl;
|
||||
|
||||
const CatalogCastMember({required this.name, this.secondary, this.imageUrl});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'name': name,
|
||||
if (secondary != null) 'secondary': secondary,
|
||||
if (imageUrl != null) 'imageUrl': imageUrl,
|
||||
};
|
||||
|
||||
static CatalogCastMember? fromJson(Map<String, Object?> json) {
|
||||
final name = json['name'] as String?;
|
||||
if (name == null) return null;
|
||||
return CatalogCastMember(
|
||||
name: name,
|
||||
secondary: json['secondary'] as String?,
|
||||
imageUrl: json['imageUrl'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import '../../media/media_backend.dart';
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../utils/external_ids.dart';
|
||||
import 'catalog_cast_member.dart';
|
||||
import 'catalog_metadata.dart';
|
||||
|
||||
/// External catalog providers that can back the Explore tab.
|
||||
enum CatalogSourceId { plex, trakt, mal, anilist, simkl, seerr }
|
||||
@@ -100,6 +102,20 @@ class CatalogItemIds {
|
||||
|
||||
ExternalIds toExternalIds() => ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
|
||||
|
||||
/// Union of both id sets, preferring this one per key. A row entry and a
|
||||
/// detail body routinely carry different subsets of the same identity.
|
||||
CatalogItemIds mergedWith(CatalogItemIds other) => CatalogItemIds(
|
||||
plex: plex ?? other.plex,
|
||||
trakt: trakt ?? other.trakt,
|
||||
slug: slug ?? other.slug,
|
||||
mal: mal ?? other.mal,
|
||||
anilist: anilist ?? other.anilist,
|
||||
simkl: simkl ?? other.simkl,
|
||||
imdb: imdb ?? other.imdb,
|
||||
tmdb: tmdb ?? other.tmdb,
|
||||
tvdb: tvdb ?? other.tvdb,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (plex != null) 'plex': plex,
|
||||
if (trakt != null) 'trakt': trakt,
|
||||
@@ -170,10 +186,152 @@ class CatalogItem {
|
||||
final String? network;
|
||||
final CatalogItemIds ids;
|
||||
|
||||
/// Absolute https URLs served by the provider's CDN.
|
||||
/// Absolute https URLs served by the provider's CDN. These stay the default
|
||||
/// asset for every surface; see [posterVariants] for size selection.
|
||||
final String? posterUrl;
|
||||
final String? backdropUrl;
|
||||
|
||||
/// Width-keyed alternates for [posterUrl] and [backdropUrl], keyed by the
|
||||
/// asset's real pixel width.
|
||||
///
|
||||
/// Providers serve the same artwork at several sizes (TMDB `w342`/`w500`,
|
||||
/// Simkl's suffixed variants). The mapper cannot pick correctly because it
|
||||
/// does not know the surface: a 92-pixel search thumbnail and a 340-pixel
|
||||
/// high-DPR TV card have opposite needs. Mappers publish what exists,
|
||||
/// widgets choose with [posterFor]/[backdropFor] once they have measured.
|
||||
final Map<int, String>? posterVariants;
|
||||
final Map<int, String>? backdropVariants;
|
||||
|
||||
/// Transparent title treatment (Trakt `images.logo`), for spotlight art.
|
||||
final String? logoUrl;
|
||||
|
||||
/// Wide banner art (Plex `banner`), distinct from the 16:9 backdrop.
|
||||
final String? bannerUrl;
|
||||
|
||||
/// Dominant cover colour as `#rrggbb` (AniList `coverImage.color`), for
|
||||
/// per-card accent tinting.
|
||||
final String? accentColor;
|
||||
|
||||
/// Scores beyond [rating], each with its own source label.
|
||||
final List<CatalogRatingSource>? ratings;
|
||||
|
||||
/// Leaderboard positions. A list because providers return several windows
|
||||
/// at once (all-time and seasonal).
|
||||
final List<CatalogRank>? ranks;
|
||||
|
||||
/// Community-size counters; null when the provider returns none.
|
||||
final CatalogAudience? audience;
|
||||
|
||||
/// Recurring weekly slot. Never render this as a next-episode date.
|
||||
final CatalogBroadcast? broadcast;
|
||||
|
||||
/// The next episode's actual air time, when the provider knows it.
|
||||
final CatalogNextEpisode? nextEpisode;
|
||||
|
||||
/// What the user's own servers have, and what has been requested.
|
||||
final CatalogServerState? serverState;
|
||||
|
||||
/// Exact premiere/release date; [year] stays the coarse fallback.
|
||||
final DateTime? releaseDate;
|
||||
|
||||
/// Home-media release (Simkl movie `released_dvd`).
|
||||
final DateTime? physicalReleaseDate;
|
||||
|
||||
/// Final air date for an ended show.
|
||||
final DateTime? endDate;
|
||||
|
||||
/// When the user added it to the list this item came from (Trakt
|
||||
/// `listed_at`, Simkl watchlist `added_to_watchlist`).
|
||||
final DateTime? addedAt;
|
||||
|
||||
/// The user's own score, 0-10, on providers that return it with the list.
|
||||
final double? userRating;
|
||||
|
||||
/// Native-language or production title when it differs from [title].
|
||||
final String? originalTitle;
|
||||
|
||||
final String? tagline;
|
||||
|
||||
/// Anime broadcast season — "Fall 2025".
|
||||
///
|
||||
/// Distinct from [season], which records *which season of a parent series*
|
||||
/// an entry maps to for library matching. This one is a calendar window.
|
||||
final CatalogSeasonInfo? broadcastSeason;
|
||||
|
||||
/// Finer-grained release format than [kind] (OVA, ONA, TV short, ...).
|
||||
final CatalogFormat? format;
|
||||
|
||||
/// What the title was adapted from.
|
||||
final CatalogSourceMaterial? sourceMaterial;
|
||||
|
||||
/// Production studios. [network] remains the single broadcast network.
|
||||
final List<String>? studios;
|
||||
|
||||
/// ISO 3166-1 alpha-2 country codes.
|
||||
final List<String>? countries;
|
||||
|
||||
/// ISO 639-1 language codes; the first is the original language.
|
||||
final List<String>? languages;
|
||||
|
||||
/// Directors, writers and producers from a detail payload.
|
||||
final List<CatalogCredit>? credits;
|
||||
|
||||
/// Ranked descriptive tags, spoilers included and flagged.
|
||||
final List<CatalogTag>? tags;
|
||||
|
||||
/// Provider and streaming links.
|
||||
final List<CatalogLink>? links;
|
||||
|
||||
/// Episodes announced but not yet broadcast (Simkl
|
||||
/// `not_aired_episodes_count`). Distinct from [episodeCount], which counts
|
||||
/// what exists, and from any watched count.
|
||||
final int? unairedEpisodeCount;
|
||||
|
||||
/// Who recommended this title and why (Trakt `favorited_by` /
|
||||
/// `recommended_by` on personalised rows). The provenance is the point of a
|
||||
/// social recommendation, so this is a list of people, not a count.
|
||||
final List<CatalogRecommender>? recommenders;
|
||||
|
||||
/// Age-appropriateness prose beyond [certification] (Plex Common Sense).
|
||||
final String? contentAdvisory;
|
||||
|
||||
/// Production budget and box-office revenue in USD.
|
||||
final int? budget;
|
||||
final int? revenue;
|
||||
|
||||
/// Provider-flagged adult content (MAL `nsfw`, TMDB `adult`).
|
||||
final bool? isAdult;
|
||||
|
||||
/// How many users recommended this item *from another item*. Only set on
|
||||
/// entries returned by a related/recommendation call.
|
||||
final int? recommendationCount;
|
||||
|
||||
/// Share of users who recommended this item *from another item*, 0-1
|
||||
/// (Simkl `users_percent`, sent as a percentage and normalized by the
|
||||
/// mapper). Independent of [recommendationCount]: a count and a share
|
||||
/// answer different questions and neither can be derived from the other.
|
||||
final double? recommendationPercent;
|
||||
|
||||
/// Play state the catalog provider itself reports.
|
||||
final CatalogPlayState? playState;
|
||||
|
||||
/// Provider search relevance. Ordering input only — never rendered.
|
||||
final double? relevance;
|
||||
|
||||
/// Extra artwork the provider ships alongside the poster (MAL `pictures`).
|
||||
/// Plain URLs: these providers publish no pixel widths, so they cannot go
|
||||
/// through [posterVariants], whose keys must be real widths.
|
||||
final List<String>? gallery;
|
||||
|
||||
/// Production/background prose (MAL `background`) — trivia about how the
|
||||
/// title came to exist, distinct from the plot [overview].
|
||||
final String? background;
|
||||
|
||||
/// Cast carried on the row item itself, for providers whose row query can
|
||||
/// select it without an extra request (AniList). Lets a detail screen skip
|
||||
/// its lazy cast call; null means "not known yet", not "no cast".
|
||||
final List<CatalogCastMember>? cast;
|
||||
|
||||
const CatalogItem({
|
||||
required this.source,
|
||||
required this.kind,
|
||||
@@ -194,11 +352,132 @@ class CatalogItem {
|
||||
required this.ids,
|
||||
this.posterUrl,
|
||||
this.backdropUrl,
|
||||
this.posterVariants,
|
||||
this.backdropVariants,
|
||||
this.logoUrl,
|
||||
this.bannerUrl,
|
||||
this.accentColor,
|
||||
this.ratings,
|
||||
this.ranks,
|
||||
this.audience,
|
||||
this.broadcast,
|
||||
this.nextEpisode,
|
||||
this.serverState,
|
||||
this.releaseDate,
|
||||
this.physicalReleaseDate,
|
||||
this.endDate,
|
||||
this.addedAt,
|
||||
this.userRating,
|
||||
this.originalTitle,
|
||||
this.tagline,
|
||||
this.broadcastSeason,
|
||||
this.format,
|
||||
this.sourceMaterial,
|
||||
this.studios,
|
||||
this.countries,
|
||||
this.languages,
|
||||
this.credits,
|
||||
this.tags,
|
||||
this.links,
|
||||
this.contentAdvisory,
|
||||
this.budget,
|
||||
this.revenue,
|
||||
this.isAdult,
|
||||
this.recommendationCount,
|
||||
this.recommendationPercent,
|
||||
this.playState,
|
||||
this.relevance,
|
||||
this.gallery,
|
||||
this.background,
|
||||
this.cast,
|
||||
this.unairedEpisodeCount,
|
||||
this.recommenders,
|
||||
});
|
||||
|
||||
/// Returns this row item enriched by a [detail] payload for the same title.
|
||||
///
|
||||
/// Detail wins for descriptive content: a detail body carries the untruncated
|
||||
/// overview, the full rating and ids the row object never had. The row wins
|
||||
/// only for values that exist *because of the row it came from* — leaderboard
|
||||
/// position, recommendation provenance, when the user listed it, their own
|
||||
/// score, and search relevance — none of which a detail endpoint knows.
|
||||
/// Ids merge per key so a row's imdb id survives a detail body that only
|
||||
/// returns tmdb.
|
||||
CatalogItem enrichedWith(CatalogItem detail) => CatalogItem(
|
||||
source: source,
|
||||
kind: kind,
|
||||
title: detail.title.isNotEmpty ? detail.title : title,
|
||||
year: detail.year ?? year,
|
||||
overview: detail.overview ?? overview,
|
||||
runtimeMinutes: detail.runtimeMinutes ?? runtimeMinutes,
|
||||
rating: detail.rating ?? rating,
|
||||
votes: detail.votes ?? votes,
|
||||
genres: detail.genres ?? genres,
|
||||
certification: detail.certification ?? certification,
|
||||
trailerUrl: detail.trailerUrl ?? trailerUrl,
|
||||
airStatus: detail.airStatus ?? airStatus,
|
||||
episodeCount: detail.episodeCount ?? episodeCount,
|
||||
network: detail.network ?? network,
|
||||
ids: ids.mergedWith(detail.ids),
|
||||
posterUrl: detail.posterUrl ?? posterUrl,
|
||||
backdropUrl: detail.backdropUrl ?? backdropUrl,
|
||||
posterVariants: detail.posterVariants ?? posterVariants,
|
||||
backdropVariants: detail.backdropVariants ?? backdropVariants,
|
||||
logoUrl: detail.logoUrl ?? logoUrl,
|
||||
bannerUrl: detail.bannerUrl ?? bannerUrl,
|
||||
accentColor: detail.accentColor ?? accentColor,
|
||||
ratings: detail.ratings ?? ratings,
|
||||
// Union rather than replace: the row and the detail body each carry
|
||||
// counters the other omits.
|
||||
audience: audience?.mergedWith(detail.audience ?? const CatalogAudience()) ?? detail.audience,
|
||||
broadcast: detail.broadcast ?? broadcast,
|
||||
nextEpisode: detail.nextEpisode ?? nextEpisode,
|
||||
serverState: detail.serverState ?? serverState,
|
||||
releaseDate: detail.releaseDate ?? releaseDate,
|
||||
physicalReleaseDate: detail.physicalReleaseDate ?? physicalReleaseDate,
|
||||
endDate: detail.endDate ?? endDate,
|
||||
originalTitle: detail.originalTitle ?? originalTitle,
|
||||
altTitles: altTitles.isNotEmpty ? altTitles : detail.altTitles,
|
||||
tagline: detail.tagline ?? tagline,
|
||||
broadcastSeason: detail.broadcastSeason ?? broadcastSeason,
|
||||
format: detail.format ?? format,
|
||||
sourceMaterial: detail.sourceMaterial ?? sourceMaterial,
|
||||
studios: detail.studios ?? studios,
|
||||
countries: detail.countries ?? countries,
|
||||
languages: detail.languages ?? languages,
|
||||
credits: detail.credits ?? credits,
|
||||
tags: detail.tags ?? tags,
|
||||
links: detail.links ?? links,
|
||||
contentAdvisory: detail.contentAdvisory ?? contentAdvisory,
|
||||
budget: detail.budget ?? budget,
|
||||
revenue: detail.revenue ?? revenue,
|
||||
isAdult: detail.isAdult ?? isAdult,
|
||||
playState: detail.playState ?? playState,
|
||||
unairedEpisodeCount: detail.unairedEpisodeCount ?? unairedEpisodeCount,
|
||||
// Row-only context: a detail endpoint cannot know these.
|
||||
ranks: ranks ?? detail.ranks,
|
||||
addedAt: addedAt ?? detail.addedAt,
|
||||
userRating: userRating ?? detail.userRating,
|
||||
recommendationCount: recommendationCount ?? detail.recommendationCount,
|
||||
recommendationPercent: recommendationPercent ?? detail.recommendationPercent,
|
||||
relevance: relevance ?? detail.relevance,
|
||||
gallery: detail.gallery ?? gallery,
|
||||
background: detail.background ?? background,
|
||||
cast: detail.cast ?? cast,
|
||||
recommenders: recommenders ?? detail.recommenders,
|
||||
);
|
||||
|
||||
/// Kind-namespaced identity key for caches and dedupe.
|
||||
String get identityKey => '${kind.id}/${ids.canonicalKey}';
|
||||
|
||||
/// The narrowest poster at least [targetPx] wide, falling back to the
|
||||
/// widest variant and finally to [posterUrl].
|
||||
String? posterFor(int targetPx) => _variantFor(posterUrl, posterVariants, targetPx);
|
||||
|
||||
/// The narrowest backdrop at least [targetPx] wide, falling back to the
|
||||
/// widest variant and finally to [backdropUrl].
|
||||
String? backdropFor(int targetPx) => _variantFor(backdropUrl, backdropVariants, targetPx);
|
||||
|
||||
/// Cache key for anything whose answer is season-specific — see
|
||||
/// [CatalogItemIds.entryKey], which [identityKey] deliberately does not use.
|
||||
String get entryIdentityKey => '${kind.id}/${ids.entryKey}';
|
||||
@@ -247,6 +526,46 @@ class CatalogItem {
|
||||
'ids': ids.toJson(),
|
||||
if (posterUrl != null) 'posterUrl': posterUrl,
|
||||
if (backdropUrl != null) 'backdropUrl': backdropUrl,
|
||||
if (posterVariants != null) 'posterVariants': {for (final e in posterVariants!.entries) '${e.key}': e.value},
|
||||
if (backdropVariants != null) 'backdropVariants': {for (final e in backdropVariants!.entries) '${e.key}': e.value},
|
||||
if (logoUrl != null) 'logoUrl': logoUrl,
|
||||
if (bannerUrl != null) 'bannerUrl': bannerUrl,
|
||||
if (accentColor != null) 'accentColor': accentColor,
|
||||
if (ratings != null) 'ratings': [for (final r in ratings!) r.toJson()],
|
||||
if (ranks != null) 'ranks': [for (final r in ranks!) r.toJson()],
|
||||
if (audience != null) 'audience': audience!.toJson(),
|
||||
if (broadcast != null) 'broadcast': broadcast!.toJson(),
|
||||
if (nextEpisode != null) 'nextEpisode': nextEpisode!.toJson(),
|
||||
if (serverState != null) 'serverState': serverState!.toJson(),
|
||||
if (releaseDate != null) 'releaseDate': releaseDate!.toIso8601String(),
|
||||
if (physicalReleaseDate != null) 'physicalReleaseDate': physicalReleaseDate!.toIso8601String(),
|
||||
if (endDate != null) 'endDate': endDate!.toIso8601String(),
|
||||
if (addedAt != null) 'addedAt': addedAt!.toIso8601String(),
|
||||
if (userRating != null) 'userRating': userRating,
|
||||
if (originalTitle != null) 'originalTitle': originalTitle,
|
||||
if (tagline != null) 'tagline': tagline,
|
||||
if (broadcastSeason != null) 'broadcastSeason': broadcastSeason!.toJson(),
|
||||
if (format != null) 'format': format!.name,
|
||||
if (sourceMaterial != null) 'sourceMaterial': sourceMaterial!.name,
|
||||
if (studios != null) 'studios': studios,
|
||||
if (countries != null) 'countries': countries,
|
||||
if (languages != null) 'languages': languages,
|
||||
if (credits != null) 'credits': [for (final c in credits!) c.toJson()],
|
||||
if (tags != null) 'tags': [for (final t in tags!) t.toJson()],
|
||||
if (links != null) 'links': [for (final l in links!) l.toJson()],
|
||||
if (unairedEpisodeCount != null) 'unairedEpisodeCount': unairedEpisodeCount,
|
||||
if (recommenders != null) 'recommenders': [for (final r in recommenders!) r.toJson()],
|
||||
if (contentAdvisory != null) 'contentAdvisory': contentAdvisory,
|
||||
if (budget != null) 'budget': budget,
|
||||
if (revenue != null) 'revenue': revenue,
|
||||
if (isAdult != null) 'isAdult': isAdult,
|
||||
if (recommendationCount != null) 'recommendationCount': recommendationCount,
|
||||
if (recommendationPercent != null) 'recommendationPercent': recommendationPercent,
|
||||
if (playState != null) 'playState': playState!.toJson(),
|
||||
if (relevance != null) 'relevance': relevance,
|
||||
if (gallery != null) 'gallery': gallery,
|
||||
if (background != null) 'background': background,
|
||||
if (cast != null) 'cast': [for (final c in cast!) c.toJson()],
|
||||
};
|
||||
|
||||
factory CatalogItem.fromJson(Map<String, Object?> json) => CatalogItem(
|
||||
@@ -278,5 +597,78 @@ class CatalogItem {
|
||||
ids: CatalogItemIds.fromJson((json['ids'] as Map?)?.cast<String, Object?>() ?? const {}),
|
||||
posterUrl: json['posterUrl'] as String?,
|
||||
backdropUrl: json['backdropUrl'] as String?,
|
||||
posterVariants: _decodeVariants(json['posterVariants']),
|
||||
backdropVariants: _decodeVariants(json['backdropVariants']),
|
||||
logoUrl: json['logoUrl'] as String?,
|
||||
bannerUrl: json['bannerUrl'] as String?,
|
||||
accentColor: json['accentColor'] as String?,
|
||||
ratings: decodeCatalogList(json['ratings'], CatalogRatingSource.fromJson),
|
||||
ranks: decodeCatalogList(json['ranks'], CatalogRank.fromJson),
|
||||
audience: _decodeObject(json['audience'], CatalogAudience.fromJson),
|
||||
broadcast: _decodeObject(json['broadcast'], CatalogBroadcast.fromJson),
|
||||
nextEpisode: _decodeObject(json['nextEpisode'], CatalogNextEpisode.fromJson),
|
||||
serverState: _decodeObject(json['serverState'], CatalogServerState.fromJson),
|
||||
releaseDate: _decodeDate(json['releaseDate']),
|
||||
physicalReleaseDate: _decodeDate(json['physicalReleaseDate']),
|
||||
endDate: _decodeDate(json['endDate']),
|
||||
addedAt: _decodeDate(json['addedAt']),
|
||||
userRating: (json['userRating'] as num?)?.toDouble(),
|
||||
originalTitle: json['originalTitle'] as String?,
|
||||
tagline: json['tagline'] as String?,
|
||||
broadcastSeason: _decodeObject(json['broadcastSeason'], CatalogSeasonInfo.fromJson),
|
||||
format: CatalogFormat.values.asNameMap()[json['format']],
|
||||
sourceMaterial: CatalogSourceMaterial.values.asNameMap()[json['sourceMaterial']],
|
||||
studios: (json['studios'] as List?)?.cast<String>(),
|
||||
countries: (json['countries'] as List?)?.cast<String>(),
|
||||
languages: (json['languages'] as List?)?.cast<String>(),
|
||||
credits: decodeCatalogList(json['credits'], CatalogCredit.fromJson),
|
||||
tags: decodeCatalogList(json['tags'], CatalogTag.fromJson),
|
||||
links: decodeCatalogList(json['links'], CatalogLink.fromJson),
|
||||
contentAdvisory: json['contentAdvisory'] as String?,
|
||||
budget: json['budget'] as int?,
|
||||
revenue: json['revenue'] as int?,
|
||||
isAdult: json['isAdult'] as bool?,
|
||||
recommendationCount: json['recommendationCount'] as int?,
|
||||
recommendationPercent: (json['recommendationPercent'] as num?)?.toDouble(),
|
||||
playState: _decodeObject(json['playState'], CatalogPlayState.fromJson),
|
||||
unairedEpisodeCount: json['unairedEpisodeCount'] as int?,
|
||||
recommenders: decodeCatalogList(json['recommenders'], CatalogRecommender.fromJson),
|
||||
relevance: (json['relevance'] as num?)?.toDouble(),
|
||||
gallery: (json['gallery'] as List?)?.cast<String>(),
|
||||
background: json['background'] as String?,
|
||||
cast: decodeCatalogList(json['cast'], CatalogCastMember.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
T? _decodeObject<T>(Object? raw, T? Function(Map<String, Object?>) decode) =>
|
||||
raw is Map ? decode(raw.cast<String, Object?>()) : null;
|
||||
|
||||
DateTime? _decodeDate(Object? raw) => raw is String ? DateTime.tryParse(raw) : null;
|
||||
|
||||
Map<int, String>? _decodeVariants(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final out = <int, String>{};
|
||||
for (final entry in raw.entries) {
|
||||
final width = int.tryParse('${entry.key}');
|
||||
final url = entry.value;
|
||||
if (width != null && url is String) out[width] = url;
|
||||
}
|
||||
return out.isEmpty ? null : out;
|
||||
}
|
||||
|
||||
/// Picks the narrowest variant that still covers [targetPx].
|
||||
///
|
||||
/// When no variant is wide enough the default asset wins, because it is the
|
||||
/// provider's canonical (usually largest) rendition and upscaling a too-small
|
||||
/// image looks worse than spending a few extra kilobytes. Mappers that know
|
||||
/// the default's own pixel width should register it in the map as well, so it
|
||||
/// competes on equal terms instead of only being a last resort.
|
||||
String? _variantFor(String? fallback, Map<int, String>? variants, int targetPx) {
|
||||
if (variants == null || variants.isEmpty) return fallback;
|
||||
int? best;
|
||||
for (final width in variants.keys) {
|
||||
if (width >= targetPx && (best == null || width < best)) best = width;
|
||||
}
|
||||
if (best == null) return fallback ?? variants[variants.keys.reduce((a, b) => a > b ? a : b)];
|
||||
return variants[best];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
/// Value objects for the richer catalog facts providers already return on the
|
||||
/// requests the Explore tab makes today.
|
||||
///
|
||||
/// These live beside [CatalogItem] rather than inside it so the item stays
|
||||
/// readable: every type here is a small, provider-neutral shape that more than
|
||||
/// one backend can fill. Serialization matches [CatalogItem]'s hand-written
|
||||
/// style — no `json_serializable` — because catalog objects only ever
|
||||
/// round-trip through `MediaItem.raw` inside a single session.
|
||||
library;
|
||||
|
||||
/// One provider score with its own source label.
|
||||
///
|
||||
/// Providers routinely return several: Simkl carries Simkl/IMDb/MAL side by
|
||||
/// side, Plex carries a critic score next to an audience score. The neutral
|
||||
/// [CatalogItem.rating] keeps the provider's headline number; this list keeps
|
||||
/// the rest with attribution so the UI can label them.
|
||||
class CatalogRatingSource {
|
||||
/// Stable, lowercase source key: `simkl`, `imdb`, `mal`, `anilist`, `tmdb`,
|
||||
/// `trakt`, `critic`, `audience`. Rendered through a label map, never raw.
|
||||
final String source;
|
||||
|
||||
/// Normalized to 0-10 by the mapper, matching [CatalogItem.rating].
|
||||
final double value;
|
||||
|
||||
/// How many users the score is based on, when the provider says.
|
||||
final int? votes;
|
||||
|
||||
const CatalogRatingSource({required this.source, required this.value, this.votes});
|
||||
|
||||
Map<String, Object?> toJson() => {'source': source, 'value': value, if (votes != null) 'votes': votes};
|
||||
|
||||
static CatalogRatingSource? fromJson(Map<String, Object?> json) {
|
||||
final source = json['source'] as String?;
|
||||
final value = (json['value'] as num?)?.toDouble();
|
||||
if (source == null || value == null) return null;
|
||||
return CatalogRatingSource(source: source, value: value, votes: json['votes'] as int?);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a leaderboard position is a position *in*.
|
||||
///
|
||||
/// MAL returns one rank per ranking endpoint, AniList returns a `rankings`
|
||||
/// array tagged by type, Simkl returns a trending rank. Normalizing the scope
|
||||
/// lets one badge render `#3 airing` or `#12 most popular` from any of them.
|
||||
enum CatalogRankScope { popular, airing, rated, favorited, trending, seasonal }
|
||||
|
||||
/// A leaderboard position within [scope], over either all time or one
|
||||
/// season/year window.
|
||||
///
|
||||
/// Providers return several at once — AniList's `rankings` array tags each
|
||||
/// entry with type, format, year, season and an `allTime` flag — so an item
|
||||
/// holds a list of these, not one. Dropping the window would turn "#3 most
|
||||
/// popular this season" into a false all-time claim.
|
||||
class CatalogRank {
|
||||
final int rank;
|
||||
final CatalogRankScope scope;
|
||||
|
||||
/// True for an all-time chart; false when [year]/[season] bound it.
|
||||
final bool allTime;
|
||||
final int? year;
|
||||
final CatalogSeasonName? season;
|
||||
|
||||
const CatalogRank({required this.rank, required this.scope, this.allTime = true, this.year, this.season});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'rank': rank,
|
||||
'scope': scope.name,
|
||||
if (!allTime) 'allTime': false,
|
||||
if (year != null) 'year': year,
|
||||
if (season != null) 'season': season!.name,
|
||||
};
|
||||
|
||||
static CatalogRank? fromJson(Map<String, Object?> json) {
|
||||
final rank = json['rank'] as int?;
|
||||
final scope = CatalogRankScope.values.asNameMap()[json['scope']];
|
||||
if (rank == null || scope == null) return null;
|
||||
return CatalogRank(
|
||||
rank: rank,
|
||||
scope: scope,
|
||||
allTime: json['allTime'] != false,
|
||||
year: json['year'] as int?,
|
||||
season: CatalogSeasonName.values.asNameMap()[json['season']],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window a windowed counter covers. Providers report popularity over a
|
||||
/// period, and rendering such a number without its period is a false claim.
|
||||
enum CatalogAudiencePeriod { day, week, month, year, allTime }
|
||||
|
||||
/// Community-size counters. Every field is optional because no provider
|
||||
/// returns all of them: Trakt has live watchers and comments, MAL/AniList have
|
||||
/// list membership and favourites, Simkl has windowed viewers, planning and a
|
||||
/// drop rate.
|
||||
class CatalogAudience {
|
||||
/// Users watching *right now* (Trakt trending `watchers`).
|
||||
final int? watchingNow;
|
||||
|
||||
/// Users with the title on any list (MAL `num_list_users`, AniList
|
||||
/// `popularity`).
|
||||
final int? listed;
|
||||
|
||||
/// Viewers within [viewersPeriod] (Simkl `watched`). This is *not* a
|
||||
/// completion count: Simkl's trending rows count viewers in the row's
|
||||
/// timeframe and its Best rows count viewers this month. Never render it
|
||||
/// without the period label.
|
||||
final int? viewers;
|
||||
final CatalogAudiencePeriod? viewersPeriod;
|
||||
|
||||
/// Users who plan to watch it (Simkl `plan_to_watch`).
|
||||
final int? planning;
|
||||
|
||||
/// Users with it in progress on their list (MAL
|
||||
/// `statistics.status.watching`). A list-status count, not the live
|
||||
/// concurrent-viewer figure in [watchingNow] — do not conflate them.
|
||||
final int? watching;
|
||||
|
||||
/// Users who finished it (MAL `statistics.status.completed`). Unlike
|
||||
/// [viewers] this is a genuine lifetime status count, not a window.
|
||||
final int? completed;
|
||||
|
||||
/// Users who paused it (MAL `statistics.status.on_hold`).
|
||||
final int? onHold;
|
||||
|
||||
/// Users who abandoned it (MAL `statistics.status.dropped`). A count, where
|
||||
/// [dropRate] is a share.
|
||||
final int? dropped;
|
||||
|
||||
/// Recent-activity score (AniList `trending`): how much the community is
|
||||
/// talking about it right now. A momentum signal, not a rank and not a
|
||||
/// headcount — it has no unit and must be rendered comparatively or not at
|
||||
/// all.
|
||||
final int? trendingActivity;
|
||||
|
||||
/// Users who favourited it (AniList `favourites`).
|
||||
final int? favorited;
|
||||
|
||||
/// Share of users who dropped it, 0-1 (Simkl `drop_rate`, sent as a
|
||||
/// percentage and normalized by the mapper).
|
||||
final double? dropRate;
|
||||
|
||||
/// Provider comment count (Trakt `comment_count`).
|
||||
final int? comments;
|
||||
|
||||
const CatalogAudience({
|
||||
this.watchingNow,
|
||||
this.listed,
|
||||
this.viewers,
|
||||
this.viewersPeriod,
|
||||
this.planning,
|
||||
this.watching,
|
||||
this.completed,
|
||||
this.onHold,
|
||||
this.dropped,
|
||||
this.trendingActivity,
|
||||
this.favorited,
|
||||
this.dropRate,
|
||||
this.comments,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
watchingNow == null &&
|
||||
listed == null &&
|
||||
viewers == null &&
|
||||
planning == null &&
|
||||
watching == null &&
|
||||
completed == null &&
|
||||
onHold == null &&
|
||||
dropped == null &&
|
||||
trendingActivity == null &&
|
||||
favorited == null &&
|
||||
dropRate == null &&
|
||||
comments == null;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (watchingNow != null) 'watchingNow': watchingNow,
|
||||
if (listed != null) 'listed': listed,
|
||||
if (viewers != null) 'viewers': viewers,
|
||||
if (viewersPeriod != null) 'viewersPeriod': viewersPeriod!.name,
|
||||
if (planning != null) 'planning': planning,
|
||||
if (watching != null) 'watching': watching,
|
||||
if (completed != null) 'completed': completed,
|
||||
if (onHold != null) 'onHold': onHold,
|
||||
if (dropped != null) 'dropped': dropped,
|
||||
if (trendingActivity != null) 'trendingActivity': trendingActivity,
|
||||
if (favorited != null) 'favorited': favorited,
|
||||
if (dropRate != null) 'dropRate': dropRate,
|
||||
if (comments != null) 'comments': comments,
|
||||
};
|
||||
|
||||
/// Field-wise union, preferring [other]'s values.
|
||||
///
|
||||
/// Counters genuinely arrive from different responses: a Simkl trending row
|
||||
/// supplies windowed [viewers] and [planning] that its detail body does not
|
||||
/// return, while the detail body supplies [dropRate] and MAL's status
|
||||
/// counts. Replacing the whole object would silently drop whichever side
|
||||
/// spoke first. [viewersPeriod] travels with [viewers] so a count is never
|
||||
/// relabelled with someone else's window.
|
||||
CatalogAudience mergedWith(CatalogAudience other) => CatalogAudience(
|
||||
watchingNow: other.watchingNow ?? watchingNow,
|
||||
listed: other.listed ?? listed,
|
||||
viewers: other.viewers ?? viewers,
|
||||
viewersPeriod: other.viewers != null ? other.viewersPeriod : viewersPeriod,
|
||||
planning: other.planning ?? planning,
|
||||
watching: other.watching ?? watching,
|
||||
completed: other.completed ?? completed,
|
||||
onHold: other.onHold ?? onHold,
|
||||
dropped: other.dropped ?? dropped,
|
||||
trendingActivity: other.trendingActivity ?? trendingActivity,
|
||||
favorited: other.favorited ?? favorited,
|
||||
dropRate: other.dropRate ?? dropRate,
|
||||
comments: other.comments ?? comments,
|
||||
);
|
||||
|
||||
factory CatalogAudience.fromJson(Map<String, Object?> json) => CatalogAudience(
|
||||
watchingNow: json['watchingNow'] as int?,
|
||||
listed: json['listed'] as int?,
|
||||
viewers: json['viewers'] as int?,
|
||||
viewersPeriod: CatalogAudiencePeriod.values.asNameMap()[json['viewersPeriod']],
|
||||
planning: json['planning'] as int?,
|
||||
watching: json['watching'] as int?,
|
||||
completed: json['completed'] as int?,
|
||||
onHold: json['onHold'] as int?,
|
||||
dropped: json['dropped'] as int?,
|
||||
trendingActivity: json['trendingActivity'] as int?,
|
||||
favorited: json['favorited'] as int?,
|
||||
dropRate: (json['dropRate'] as num?)?.toDouble(),
|
||||
comments: json['comments'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// A recurring weekly broadcast slot (Trakt `airs`, MAL `broadcast`).
|
||||
///
|
||||
/// This is a schedule, not a date: it says "Tuesdays at 21:00" and must never
|
||||
/// be rendered as the next episode's air date. Use [CatalogNextEpisode] for
|
||||
/// that.
|
||||
class CatalogBroadcast {
|
||||
/// ISO-8601 weekday, Monday = 1 through Sunday = 7.
|
||||
final int? weekday;
|
||||
|
||||
/// Local broadcast time as `HH:mm` in [timezone].
|
||||
final String? time;
|
||||
|
||||
/// IANA zone name, e.g. `America/New_York` or `Asia/Tokyo`.
|
||||
final String? timezone;
|
||||
|
||||
const CatalogBroadcast({this.weekday, this.time, this.timezone});
|
||||
|
||||
bool get isEmpty => weekday == null && time == null;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (weekday != null) 'weekday': weekday,
|
||||
if (time != null) 'time': time,
|
||||
if (timezone != null) 'timezone': timezone,
|
||||
};
|
||||
|
||||
factory CatalogBroadcast.fromJson(Map<String, Object?> json) => CatalogBroadcast(
|
||||
weekday: json['weekday'] as int?,
|
||||
time: json['time'] as String?,
|
||||
timezone: json['timezone'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// The next episode's actual air time (AniList `nextAiringEpisode`).
|
||||
class CatalogNextEpisode {
|
||||
final int? episode;
|
||||
final DateTime airsAt;
|
||||
|
||||
const CatalogNextEpisode({required this.airsAt, this.episode});
|
||||
|
||||
/// Remaining time from [now], clamped at zero once the slot has passed.
|
||||
Duration timeUntil(DateTime now) {
|
||||
final delta = airsAt.difference(now);
|
||||
return delta.isNegative ? Duration.zero : delta;
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'airsAt': airsAt.toUtc().toIso8601String(),
|
||||
if (episode != null) 'episode': episode,
|
||||
};
|
||||
|
||||
static CatalogNextEpisode? fromJson(Map<String, Object?> json) {
|
||||
final airsAt = DateTime.tryParse(json['airsAt'] as String? ?? '');
|
||||
if (airsAt == null) return null;
|
||||
return CatalogNextEpisode(airsAt: airsAt, episode: json['episode'] as int?);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the title exists on the user's own media servers, per quality tier.
|
||||
enum CatalogAvailability { unavailable, partiallyAvailable, available }
|
||||
|
||||
/// Where a request for the title stands in the approval/download pipeline.
|
||||
enum CatalogRequestState { pending, approved, processing, declined, failed }
|
||||
|
||||
/// Seerr's server-side knowledge about a discovery result.
|
||||
///
|
||||
/// Availability and request state are deliberately independent: a show can be
|
||||
/// fully available in HD while a 4K request for it is still pending approval,
|
||||
/// and collapsing both into one ladder would lose that.
|
||||
class CatalogServerState {
|
||||
final CatalogAvailability? availability;
|
||||
final CatalogAvailability? availability4k;
|
||||
final CatalogRequestState? request;
|
||||
final CatalogRequestState? request4k;
|
||||
|
||||
/// Seasons already on the server, when the provider breaks it down.
|
||||
final int? availableSeasons;
|
||||
final int? totalSeasons;
|
||||
|
||||
const CatalogServerState({
|
||||
this.availability,
|
||||
this.availability4k,
|
||||
this.request,
|
||||
this.request4k,
|
||||
this.availableSeasons,
|
||||
this.totalSeasons,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
availability == null &&
|
||||
availability4k == null &&
|
||||
request == null &&
|
||||
request4k == null &&
|
||||
availableSeasons == null &&
|
||||
totalSeasons == null;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (availability != null) 'availability': availability!.name,
|
||||
if (availability4k != null) 'availability4k': availability4k!.name,
|
||||
if (request != null) 'request': request!.name,
|
||||
if (request4k != null) 'request4k': request4k!.name,
|
||||
if (availableSeasons != null) 'availableSeasons': availableSeasons,
|
||||
if (totalSeasons != null) 'totalSeasons': totalSeasons,
|
||||
};
|
||||
|
||||
factory CatalogServerState.fromJson(Map<String, Object?> json) => CatalogServerState(
|
||||
availability: CatalogAvailability.values.asNameMap()[json['availability']],
|
||||
availability4k: CatalogAvailability.values.asNameMap()[json['availability4k']],
|
||||
request: CatalogRequestState.values.asNameMap()[json['request']],
|
||||
request4k: CatalogRequestState.values.asNameMap()[json['request4k']],
|
||||
availableSeasons: json['availableSeasons'] as int?,
|
||||
totalSeasons: json['totalSeasons'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Anime broadcast season (AniList `season`/`seasonYear`, MAL `start_season`).
|
||||
enum CatalogSeasonName { winter, spring, summer, fall }
|
||||
|
||||
class CatalogSeasonInfo {
|
||||
final CatalogSeasonName name;
|
||||
final int? year;
|
||||
|
||||
const CatalogSeasonInfo({required this.name, this.year});
|
||||
|
||||
Map<String, Object?> toJson() => {'name': name.name, if (year != null) 'year': year};
|
||||
|
||||
static CatalogSeasonInfo? fromJson(Map<String, Object?> json) {
|
||||
final name = CatalogSeasonName.values.asNameMap()[json['name']];
|
||||
if (name == null) return null;
|
||||
return CatalogSeasonInfo(name: name, year: json['year'] as int?);
|
||||
}
|
||||
|
||||
/// Accepts AniList's `FALL`/`SUMMER` and MAL's `fall`/`summer` alike.
|
||||
static CatalogSeasonName? parseName(String? raw) =>
|
||||
raw == null ? null : CatalogSeasonName.values.asNameMap()[raw.toLowerCase()];
|
||||
}
|
||||
|
||||
/// Release format, finer-grained than [MediaKind] (AniList `format`, MAL
|
||||
/// `media_type`). Kept separate so `MediaKind.show` can still be an OVA.
|
||||
enum CatalogFormat { tv, tvShort, movie, special, ova, ona, music, other }
|
||||
|
||||
/// What the title was adapted from (AniList/MAL `source`).
|
||||
enum CatalogSourceMaterial { original, manga, lightNovel, novel, visualNovel, game, webComic, musicRelease, otherMedia }
|
||||
|
||||
/// A non-cast credit: director, writer, producer or creator.
|
||||
enum CatalogCreditRole { director, writer, producer, creator, composer }
|
||||
|
||||
class CatalogCredit {
|
||||
final String name;
|
||||
final CatalogCreditRole role;
|
||||
|
||||
const CatalogCredit({required this.name, required this.role});
|
||||
|
||||
Map<String, Object?> toJson() => {'name': name, 'role': role.name};
|
||||
|
||||
static CatalogCredit? fromJson(Map<String, Object?> json) {
|
||||
final name = json['name'] as String?;
|
||||
final role = CatalogCreditRole.values.asNameMap()[json['role']];
|
||||
if (name == null || role == null) return null;
|
||||
return CatalogCredit(name: name, role: role);
|
||||
}
|
||||
}
|
||||
|
||||
/// A ranked descriptive tag (AniList `tags`). Distinct from genres: there are
|
||||
/// many, they are user-ranked, and some spoil the plot.
|
||||
class CatalogTag {
|
||||
final String name;
|
||||
|
||||
/// 0-100 community relevance, when the provider ranks them.
|
||||
final int? rank;
|
||||
final bool isSpoiler;
|
||||
|
||||
const CatalogTag({required this.name, this.rank, this.isSpoiler = false});
|
||||
|
||||
Map<String, Object?> toJson() => {'name': name, if (rank != null) 'rank': rank, if (isSpoiler) 'isSpoiler': true};
|
||||
|
||||
static CatalogTag? fromJson(Map<String, Object?> json) {
|
||||
final name = json['name'] as String?;
|
||||
if (name == null) return null;
|
||||
return CatalogTag(name: name, rank: json['rank'] as int?, isSpoiler: json['isSpoiler'] == true);
|
||||
}
|
||||
}
|
||||
|
||||
/// An outbound link the provider supplies (AniList `externalLinks` and
|
||||
/// `streamingEpisodes`, Simkl's canonical `url`).
|
||||
class CatalogLink {
|
||||
/// Site name as the provider gives it — a proper noun, never translated.
|
||||
final String label;
|
||||
final String url;
|
||||
|
||||
/// Marks a link that plays the title rather than describing it, so the UI
|
||||
/// can group "watch on" separately from "read about".
|
||||
final bool isStreaming;
|
||||
|
||||
const CatalogLink({required this.label, required this.url, this.isStreaming = false});
|
||||
|
||||
Map<String, Object?> toJson() => {'label': label, 'url': url, if (isStreaming) 'isStreaming': true};
|
||||
|
||||
static CatalogLink? fromJson(Map<String, Object?> json) {
|
||||
final label = json['label'] as String?;
|
||||
final url = json['url'] as String?;
|
||||
if (label == null || url == null) return null;
|
||||
return CatalogLink(label: label, url: url, isStreaming: json['isStreaming'] == true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Play state the catalog provider itself knows about (Plex Discover
|
||||
/// `includeUserState=1`). Independent of any connected media server's state.
|
||||
class CatalogPlayState {
|
||||
final int? viewCount;
|
||||
final int? viewOffsetMs;
|
||||
final int? viewedLeafCount;
|
||||
|
||||
const CatalogPlayState({this.viewCount, this.viewOffsetMs, this.viewedLeafCount});
|
||||
|
||||
bool get isEmpty => viewCount == null && viewOffsetMs == null && viewedLeafCount == null;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (viewCount != null) 'viewCount': viewCount,
|
||||
if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs,
|
||||
if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount,
|
||||
};
|
||||
|
||||
factory CatalogPlayState.fromJson(Map<String, Object?> json) => CatalogPlayState(
|
||||
viewCount: json['viewCount'] as int?,
|
||||
viewOffsetMs: json['viewOffsetMs'] as int?,
|
||||
viewedLeafCount: json['viewedLeafCount'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Decodes a JSON list of objects into value objects, dropping malformed
|
||||
/// siblings rather than failing the whole item — catalog payloads are
|
||||
/// best-effort API data, not a strict persisted discriminator.
|
||||
List<T>? decodeCatalogList<T>(Object? raw, T? Function(Map<String, Object?>) decode) {
|
||||
if (raw is! List) return null;
|
||||
final out = <T>[];
|
||||
for (final entry in raw) {
|
||||
if (entry is! Map) continue;
|
||||
final decoded = decode(entry.cast<String, Object?>());
|
||||
if (decoded != null) out.add(decoded);
|
||||
}
|
||||
return out.isEmpty ? null : out;
|
||||
}
|
||||
|
||||
/// Why a person appears against a recommended title.
|
||||
enum CatalogRecommendationReason { favorited, recommended }
|
||||
|
||||
/// A user whose activity produced a recommendation (Trakt `favorited_by` and
|
||||
/// `recommended_by` on personalised recommendation rows).
|
||||
///
|
||||
/// Counting these arrays would misstate the contract: the provenance — who,
|
||||
/// and what they said about it — is the entire value of a social
|
||||
/// recommendation.
|
||||
class CatalogRecommender {
|
||||
final String username;
|
||||
final String? name;
|
||||
|
||||
/// Free-text note the user attached to the recommendation.
|
||||
final String? note;
|
||||
final CatalogRecommendationReason reason;
|
||||
|
||||
const CatalogRecommender({required this.username, required this.reason, this.name, this.note});
|
||||
|
||||
/// Display name when the user set one, otherwise the handle.
|
||||
String get displayName => name?.isNotEmpty == true ? name! : username;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'username': username,
|
||||
'reason': reason.name,
|
||||
if (name != null) 'name': name,
|
||||
if (note != null) 'note': note,
|
||||
};
|
||||
|
||||
static CatalogRecommender? fromJson(Map<String, Object?> json) {
|
||||
final username = json['username'] as String?;
|
||||
final reason = CatalogRecommendationReason.values.asNameMap()[json['reason']];
|
||||
if (username == null || reason == null) return null;
|
||||
return CatalogRecommender(
|
||||
username: username,
|
||||
reason: reason,
|
||||
name: json['name'] as String?,
|
||||
note: json['note'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user