Files
plezy/lib/utils/json_utils.dart
T
edde746 27acbaf435 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.
2026-07-29 06:47:54 +02:00

164 lines
6.0 KiB
Dart

/// Parse a value that may be [int], [num], or [String] to [int].
/// Used as `@JsonKey(fromJson: flexibleInt)` and in manual `fromJson` factories
/// to handle Plex API responses where numeric fields may arrive as strings
/// (XML-to-JSON conversion).
int? flexibleInt(Object? v) => switch (v) {
final num n => n.toInt(),
final String s => int.tryParse(s),
_ => null,
};
int flexibleIntOrZero(Object? v) => flexibleInt(v) ?? 0;
String stringOrEmpty(Object? v) => (v ?? '').toString();
/// Parse a value that may be [bool], [int] (0/1), or [String] ('1'/'true'/'false') to [bool].
/// Returns `false` for `null` or unrecognised values.
/// Handles Plex API responses where boolean fields may arrive as integers.
bool flexibleBool(Object? v) => switch (v) {
final bool b => b,
final int n => n == 1,
final String s => s == '1' || s.toLowerCase() == 'true',
_ => false,
};
/// Parse a value that may be [bool], [int] (0/1), or [String] ('1'/'true'/'false') to [bool].
/// Returns `null` for `null` or unsupported non-string values; legacy string
/// values other than `'1'`/`'true'` map to `false`.
bool? flexibleBoolNullable(Object? v) => switch (v) {
final bool b => b,
final int n => n == 1,
final String s => s == '1' || s.toLowerCase() == 'true',
_ => null,
};
/// Parse a value that may be [double], [num], or [String] to [double].
double? flexibleDouble(Object? v) => switch (v) {
final num n => n.toDouble(),
final String s => double.tryParse(s),
_ => null,
};
/// `@JsonKey(readValue:)` adapter — coerces the named field to a String via
/// `toString()` before the generated cast. Use for required `String` fields
/// that Plex may return as int in some endpoints.
Object? readStringField(Map json, String key) => json[key]?.toString();
/// Coerce a value that may be a single Map or a List of Maps into a `List<dynamic>`.
/// Plex often returns `{"Part": {...}}` for single-part media and
/// `{"Part": [{...}, {...}]}` for multi-part — this normalises both shapes.
/// Returns `null` when the value is `null`.
List<dynamic>? flexibleList(Object? v) => switch (v) {
null => null,
final List l => l,
_ => <dynamic>[v],
};
/// Return only JSON object entries from a value that may be one object, a
/// heterogeneous list, or null.
List<Map<String, dynamic>> flexibleMapList(Object? value) {
return [
for (final item in flexibleList(value) ?? const <dynamic>[])
if (item is Map<String, dynamic>) item,
];
}
/// Return the first JSON object from a single object or heterogeneous list.
Map<String, dynamic>? firstFlexibleMap(Object? value) {
for (final item in flexibleList(value) ?? const <dynamic>[]) {
if (item is Map<String, dynamic>) return item;
}
return null;
}
/// Parse every valid JSON object independently, dropping malformed entries
/// instead of letting one row discard an otherwise usable response.
List<T> parseFlexibleJsonList<T>(Object? value, T Function(Map<String, dynamic> json) parse) {
final result = <T>[];
for (final json in flexibleMapList(value)) {
try {
result.add(parse(json));
} catch (_) {
// A malformed row does not invalidate its siblings.
}
}
return result;
}
/// Parse the first JSON object, returning null for missing or malformed data.
T? parseFlexibleJsonObject<T>(Object? value, T Function(Map<String, dynamic> json) parse) {
final json = firstFlexibleMap(value);
if (json == null) return null;
try {
return parse(json);
} catch (_) {
return null;
}
}
/// Coerce a single String, a List of Strings, or null into `List<String>?`.
/// Non-string elements are dropped; an empty result (or null input) yields
/// `null`. Typed sibling of [flexibleList] — a bare String is wrapped into a
/// single-element list, so callers tolerate both single-value and list shapes.
List<String>? flexibleStringList(Object? v) {
final list = flexibleList(v);
if (list == null) return null;
final result = [
for (final e in list)
if (e is String) e,
];
return result.isEmpty ? null : result;
}
/// Coerce a comma-separated String ("en,sv"), a bare String, a List of
/// Strings, or null into `List<String>?`. Since ~July 2026 the Plex account
/// API (clients.plex.tv `/api/v2/user` and `/home/users/{uuid}/switch`)
/// returns the profile language-list fields as CSV strings instead of arrays
/// (#1488) — this tolerates both shapes. Parts are trimmed and empties
/// dropped; an empty result (or null input) yields `null`. CSV-splitting
/// sibling of [flexibleStringList], kept separate so that caller's strings
/// (Fribb IMDb ids) stay verbatim.
List<String>? flexibleCsvStringList(Object? v) {
final strings = flexibleStringList(v);
if (strings == null) return null;
final result = [
for (final s in strings)
for (final part in s.split(','))
if (part.trim().isNotEmpty) part.trim(),
];
return result.isEmpty ? null : result;
}
List<String>? stringListFromRaw(Object? raw, {String? mapKey, bool stringify = false, bool nullIfEmpty = false}) {
if (raw is! List) return null;
final result = <String>[];
for (final value in raw) {
final source = mapKey != null && value is Map ? value[mapKey] : value;
final string = stringify
? source?.toString()
: source is String
? source
: null;
if (string != null) result.add(string);
}
if (result.isEmpty && nullIfEmpty) return null;
return result;
}
List<T>? nullIfEmptyList<T>(List<T> values) => values.isEmpty ? null : values;
/// First entry that is neither null nor blank after trimming, else null.
///
/// Remote APIs distinguish "absent" from "present but empty" inconsistently.
/// TMDB in particular answers a `language=` query for an untranslated title or
/// overview with an empty string rather than omitting the field, so `??`
/// fallback chains silently select the blank. Use this where a localized value
/// must degrade to an untranslated one.
String? firstNonBlank(Iterable<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
}
return null;
}