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
487 lines
18 KiB
Dart
487 lines
18 KiB
Dart
/// 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;
|
|
|
|
/// 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?,
|
|
);
|
|
}
|
|
}
|