1156 lines
38 KiB
Dart
1156 lines
38 KiB
Dart
// Pure JSON/DTO→neutral-type mappers for Plex. Mirrors [JellyfinMappers].
|
|
//
|
|
// The DTO layer ([PlexMetadataDto] etc.) is a typed shim over the raw
|
|
// `/library/metadata` JSON shape that exists so the Plex-specific quirks
|
|
// (heterogeneous tags, obfuscation, the OnDeck nesting) can be handled
|
|
// once. The [PlexMappers] class is a thin public wrapper that converts
|
|
// either parsed DTOs or raw JSON into the neutral
|
|
// [MediaItem] / [MediaLibrary] / [MediaHub] / [MediaPlaylist] types.
|
|
//
|
|
// Pure: no HTTP, no client state, no token-aware image-URL resolution.
|
|
// The client wraps the static methods with per-instance image-URL
|
|
// resolution and server-tagging.
|
|
|
|
import 'package:json_annotation/json_annotation.dart';
|
|
import '../media/ids.dart';
|
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
|
|
import '../media/media_backend.dart';
|
|
import '../media/media_display_criteria.dart';
|
|
import '../media/media_hub.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_kind.dart';
|
|
import '../media/media_library.dart';
|
|
import '../media/media_part.dart';
|
|
import '../media/media_playlist.dart';
|
|
import '../media/media_role.dart';
|
|
import '../media/media_source_info.dart';
|
|
import '../media/media_version.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
import '../utils/json_utils.dart';
|
|
import '../utils/obfuscation_utils.dart';
|
|
import 'file_info_parser.dart';
|
|
|
|
part 'plex_mappers.g.dart';
|
|
|
|
/// Shared suffix of both unmatched-agent URL schemes: legacy
|
|
/// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`.
|
|
const _unmatchedAgentMarker = 'agents.none://';
|
|
|
|
Map<String, dynamic> _obfuscatePlaylistJson(Map<String, dynamic> json) {
|
|
final copy = Map<String, dynamic>.from(json);
|
|
for (final key in const ['title', 'summary']) {
|
|
if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String);
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
int _flexibleIntOrZero(Object? v) => flexibleInt(v) ?? 0;
|
|
|
|
Map? _firstPartMap(Object? raw) {
|
|
final parts = _partMaps(raw);
|
|
return parts.isEmpty ? null : parts.first;
|
|
}
|
|
|
|
List<Map> _partMaps(Object? raw) {
|
|
final parts = flexibleList(raw);
|
|
if (parts == null || parts.isEmpty) return const [];
|
|
return [
|
|
for (final part in parts)
|
|
if (part is Map) part,
|
|
];
|
|
}
|
|
|
|
String _partKeyFromJson(Object? raw) => _firstPartMap(raw)?['key']?.toString() ?? '';
|
|
|
|
bool? _partAccessibleFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap(raw)?['accessible']);
|
|
|
|
bool? _partExistsFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap(raw)?['exists']);
|
|
|
|
MediaPart _mediaPartFromMap(Map json, {required String fallbackId, String? fallbackContainer}) {
|
|
return MediaPart(
|
|
id: (json['id'] ?? fallbackId).toString(),
|
|
streamPath: json['key']?.toString(),
|
|
sizeBytes: flexibleInt(json['size']),
|
|
container: json['container']?.toString() ?? fallbackContainer,
|
|
durationMs: flexibleInt(json['duration']),
|
|
accessible: flexibleBoolNullable(json['accessible']),
|
|
exists: flexibleBoolNullable(json['exists']),
|
|
);
|
|
}
|
|
|
|
List<MediaPart> _mediaPartsFromJson(Object? raw, {required String fallbackId, String? fallbackContainer}) {
|
|
final partMaps = _partMaps(raw);
|
|
if (partMaps.isEmpty) return const [];
|
|
return [
|
|
for (var i = 0; i < partMaps.length; i++)
|
|
_mediaPartFromMap(
|
|
partMaps[i],
|
|
fallbackId: i == 0 ? fallbackId : '$fallbackId:$i',
|
|
fallbackContainer: fallbackContainer,
|
|
),
|
|
];
|
|
}
|
|
|
|
Object? _readPartKey(Map json, String _) => _partKeyFromJson(json['Part']);
|
|
|
|
Object? _readPartAccessible(Map json, String _) => _partAccessibleFromJson(json['Part']);
|
|
|
|
Object? _readPartExists(Map json, String _) => _partExistsFromJson(json['Part']);
|
|
|
|
String _hubTitleFromJson(Object? raw) {
|
|
final title = raw as String? ?? 'Unknown';
|
|
return kBlurArtwork ? obfuscateText(title) : title;
|
|
}
|
|
|
|
Object? _readHubItems(Map json, String _) {
|
|
final entries = <Map<String, dynamic>>[];
|
|
|
|
void append(Object? raw, {required bool isDirectory}) {
|
|
if (raw is! List) return;
|
|
for (final item in raw) {
|
|
if (item is! Map) continue;
|
|
final entry = Map<String, dynamic>.from(item);
|
|
if (isDirectory && !entry.containsKey('type')) {
|
|
entry['type'] = (entry.containsKey('leafCount') || entry.containsKey('childCount')) ? 'show' : 'folder';
|
|
}
|
|
entries.add(entry);
|
|
}
|
|
}
|
|
|
|
append(json['Metadata'], isDirectory: false);
|
|
append(json['Directory'], isDirectory: true);
|
|
return entries;
|
|
}
|
|
|
|
List<PlexMetadataDto> _hubItemsFromJson(Object? raw) {
|
|
final items = <PlexMetadataDto>[];
|
|
if (raw is! List) return items;
|
|
for (final item in raw) {
|
|
try {
|
|
items.add(PlexMetadataDto.fromJsonWithImages(item as Map<String, dynamic>));
|
|
} catch (_) {
|
|
// Skip hub entries that fail to parse; Plex hubs can mix item shapes.
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
Object? _readMetadataRatingKey(Map json, String _) => (json['ratingKey'] ?? json['key'])?.toString() ?? '';
|
|
|
|
List<String>? _tagListFromJson(Object? raw) => stringListFromRaw(raw, mapKey: 'tag');
|
|
|
|
String? _stringOrNull(Object? value) {
|
|
final string = value?.toString().trim();
|
|
return string == null || string.isEmpty ? null : string;
|
|
}
|
|
|
|
String _normalizedDisplayColorTags(String? transfer, String? primaries, String? matrix) =>
|
|
[transfer, primaries, matrix].whereType<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
|
|
|
|
@JsonSerializable(createToJson: false)
|
|
class PlexRoleDto {
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? id;
|
|
final String? filter;
|
|
final String tag;
|
|
final String? tagKey;
|
|
final String? role;
|
|
final String? thumb;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? count;
|
|
|
|
const PlexRoleDto({this.id, this.filter, required this.tag, this.tagKey, this.role, this.thumb, this.count});
|
|
|
|
factory PlexRoleDto.fromJson(Map<String, dynamic> json) => _$PlexRoleDtoFromJson(json);
|
|
}
|
|
|
|
@JsonSerializable(createToJson: false)
|
|
class PlexMediaVersionDto {
|
|
@JsonKey(fromJson: _flexibleIntOrZero)
|
|
final int id;
|
|
@JsonKey(readValue: readStringField)
|
|
final String? videoResolution;
|
|
@JsonKey(readValue: readStringField)
|
|
final String? videoCodec;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? bitrate;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? width;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? height;
|
|
@JsonKey(readValue: readStringField)
|
|
final String? container;
|
|
@JsonKey(readValue: _readPartKey)
|
|
final String partKey;
|
|
@JsonKey(readValue: _readPartAccessible)
|
|
final bool? accessible;
|
|
@JsonKey(readValue: _readPartExists)
|
|
final bool? exists;
|
|
|
|
const PlexMediaVersionDto({
|
|
required this.id,
|
|
this.videoResolution,
|
|
this.videoCodec,
|
|
this.bitrate,
|
|
this.width,
|
|
this.height,
|
|
this.container,
|
|
required this.partKey,
|
|
this.accessible,
|
|
this.exists,
|
|
});
|
|
|
|
factory PlexMediaVersionDto.fromJson(Map<String, dynamic> json) => _$PlexMediaVersionDtoFromJson(json);
|
|
}
|
|
|
|
@JsonSerializable(createToJson: false)
|
|
class PlexLibraryDto {
|
|
@JsonKey(readValue: readStringField, defaultValue: '')
|
|
final String key;
|
|
@JsonKey(defaultValue: '')
|
|
final String title;
|
|
@JsonKey(defaultValue: '')
|
|
final String type;
|
|
final String? agent;
|
|
final String? scanner;
|
|
final String? language;
|
|
final String? uuid;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? updatedAt;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? createdAt;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? hidden;
|
|
@JsonKey(includeFromJson: false)
|
|
final String? serverId;
|
|
@JsonKey(includeFromJson: false)
|
|
final String? serverName;
|
|
@JsonKey(includeFromJson: false)
|
|
final bool isShared;
|
|
|
|
const PlexLibraryDto({
|
|
required this.key,
|
|
required this.title,
|
|
required this.type,
|
|
this.agent,
|
|
this.scanner,
|
|
this.language,
|
|
this.uuid,
|
|
this.updatedAt,
|
|
this.createdAt,
|
|
this.hidden,
|
|
this.serverId,
|
|
this.serverName,
|
|
this.isShared = false,
|
|
});
|
|
|
|
factory PlexLibraryDto.fromJson(Map<String, dynamic> json) => _$PlexLibraryDtoFromJson(json);
|
|
|
|
PlexLibraryDto copyWith({ServerId? serverId, String? serverName, bool? isShared}) {
|
|
return PlexLibraryDto(
|
|
key: key,
|
|
title: title,
|
|
type: type,
|
|
agent: agent,
|
|
scanner: scanner,
|
|
language: language,
|
|
uuid: uuid,
|
|
updatedAt: updatedAt,
|
|
createdAt: createdAt,
|
|
hidden: hidden,
|
|
serverId: serverId ?? this.serverId,
|
|
serverName: serverName ?? this.serverName,
|
|
isShared: isShared ?? this.isShared,
|
|
);
|
|
}
|
|
|
|
String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), key) : key;
|
|
}
|
|
|
|
@JsonSerializable(createToJson: false)
|
|
class PlexPlaylistDto {
|
|
@JsonKey(readValue: readStringField, defaultValue: '')
|
|
final String ratingKey;
|
|
@JsonKey(defaultValue: '')
|
|
final String key;
|
|
@JsonKey(defaultValue: '')
|
|
final String type;
|
|
@JsonKey(defaultValue: '')
|
|
final String title;
|
|
final String? summary;
|
|
@JsonKey(defaultValue: false)
|
|
final bool smart;
|
|
@JsonKey(defaultValue: '')
|
|
final String playlistType;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? duration;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? leafCount;
|
|
final String? composite;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? addedAt;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? updatedAt;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? lastViewedAt;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? viewCount;
|
|
final String? content;
|
|
final String? guid;
|
|
final String? thumb;
|
|
@JsonKey(includeFromJson: false)
|
|
final String? serverId;
|
|
@JsonKey(includeFromJson: false)
|
|
final String? serverName;
|
|
|
|
const PlexPlaylistDto({
|
|
required this.ratingKey,
|
|
required this.key,
|
|
required this.type,
|
|
required this.title,
|
|
this.summary,
|
|
required this.smart,
|
|
required this.playlistType,
|
|
this.duration,
|
|
this.leafCount,
|
|
this.composite,
|
|
this.addedAt,
|
|
this.updatedAt,
|
|
this.lastViewedAt,
|
|
this.viewCount,
|
|
this.content,
|
|
this.guid,
|
|
this.thumb,
|
|
this.serverId,
|
|
this.serverName,
|
|
});
|
|
|
|
factory PlexPlaylistDto.fromJson(Map<String, dynamic> json) =>
|
|
_$PlexPlaylistDtoFromJson(kBlurArtwork ? _obfuscatePlaylistJson(json) : json);
|
|
|
|
PlexPlaylistDto copyWith({ServerId? serverId, String? serverName}) {
|
|
return PlexPlaylistDto(
|
|
ratingKey: ratingKey,
|
|
key: key,
|
|
type: type,
|
|
title: title,
|
|
summary: summary,
|
|
smart: smart,
|
|
playlistType: playlistType,
|
|
duration: duration,
|
|
leafCount: leafCount,
|
|
composite: composite,
|
|
addedAt: addedAt,
|
|
updatedAt: updatedAt,
|
|
lastViewedAt: lastViewedAt,
|
|
viewCount: viewCount,
|
|
content: content,
|
|
guid: guid,
|
|
thumb: thumb,
|
|
serverId: serverId ?? this.serverId,
|
|
serverName: serverName ?? this.serverName,
|
|
);
|
|
}
|
|
}
|
|
|
|
@JsonSerializable(createToJson: false)
|
|
class PlexHubDto {
|
|
@JsonKey(name: 'key', readValue: readStringField, defaultValue: '')
|
|
final String hubKey;
|
|
@JsonKey(fromJson: _hubTitleFromJson)
|
|
final String title;
|
|
@JsonKey(defaultValue: 'hub')
|
|
final String type;
|
|
final String? hubIdentifier;
|
|
@JsonKey(fromJson: _flexibleIntOrZero)
|
|
final int size;
|
|
@JsonKey(fromJson: flexibleBool)
|
|
final bool more;
|
|
@JsonKey(readValue: _readHubItems, fromJson: _hubItemsFromJson)
|
|
final List<PlexMetadataDto> items;
|
|
@JsonKey(includeFromJson: false)
|
|
final String? serverId;
|
|
@JsonKey(includeFromJson: false)
|
|
final String? serverName;
|
|
|
|
const PlexHubDto({
|
|
required this.hubKey,
|
|
required this.title,
|
|
required this.type,
|
|
this.hubIdentifier,
|
|
required this.size,
|
|
required this.more,
|
|
required this.items,
|
|
this.serverId,
|
|
this.serverName,
|
|
});
|
|
|
|
factory PlexHubDto.fromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
|
|
final parsed = _$PlexHubDtoFromJson(json);
|
|
final items = serverId == null && serverName == null
|
|
? parsed.items
|
|
: parsed.items.map((item) => item.copyWith(serverId: serverId, serverName: serverName)).toList();
|
|
|
|
return PlexHubDto(
|
|
hubKey: parsed.hubKey,
|
|
title: parsed.title,
|
|
type: parsed.type,
|
|
hubIdentifier: parsed.hubIdentifier,
|
|
size: flexibleInt(json['size']) ?? items.length,
|
|
more: parsed.more,
|
|
items: items,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
);
|
|
}
|
|
}
|
|
|
|
@JsonSerializable(includeIfNull: false)
|
|
class PlexMetadataDto {
|
|
@JsonKey(readValue: _readMetadataRatingKey, defaultValue: '')
|
|
final String ratingKey;
|
|
final String? key;
|
|
final String? guid;
|
|
final String? studio;
|
|
final String? type;
|
|
final String? title;
|
|
final String? titleSort;
|
|
final String? contentRating;
|
|
final String? summary;
|
|
final double? rating;
|
|
final double? audienceRating;
|
|
final double? userRating;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? year;
|
|
final String? originallyAvailableAt;
|
|
final String? thumb;
|
|
final String? art;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? duration;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? addedAt;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? updatedAt;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? lastViewedAt;
|
|
final String? grandparentTitle;
|
|
final String? grandparentThumb;
|
|
final String? grandparentArt;
|
|
@JsonKey(readValue: readStringField)
|
|
final String? grandparentRatingKey;
|
|
final String? parentTitle;
|
|
final String? parentThumb;
|
|
@JsonKey(readValue: readStringField)
|
|
final String? parentRatingKey;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? parentIndex;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? index;
|
|
final String? grandparentTheme;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? viewOffset;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? viewCount;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? leafCount;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? viewedLeafCount;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? childCount;
|
|
@JsonKey(name: 'Role', includeToJson: false)
|
|
final List<PlexRoleDto>? role;
|
|
@JsonKey(name: 'Media', includeToJson: false)
|
|
final List<PlexMediaVersionDto>? mediaVersions;
|
|
@JsonKey(name: 'Genre', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? genre;
|
|
@JsonKey(name: 'Director', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? director;
|
|
@JsonKey(name: 'Writer', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? writer;
|
|
@JsonKey(name: 'Producer', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? producer;
|
|
@JsonKey(name: 'Country', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? country;
|
|
@JsonKey(name: 'Collection', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? collection;
|
|
@JsonKey(name: 'Label', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? label;
|
|
@JsonKey(name: 'Style', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? style;
|
|
@JsonKey(name: 'Mood', fromJson: _tagListFromJson, includeToJson: false)
|
|
final List<String>? mood;
|
|
final String? audioLanguage;
|
|
final String? subtitleLanguage;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? subtitleMode;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? playlistItemID;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? playQueueItemID;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? librarySectionID;
|
|
final String? librarySectionTitle;
|
|
final String? ratingImage;
|
|
final String? audienceRatingImage;
|
|
final String? tagline;
|
|
final String? originalTitle;
|
|
final String? editionTitle;
|
|
final String? subtype;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? extraType;
|
|
final String? primaryExtraKey;
|
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
final String? serverId;
|
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
final String? serverName;
|
|
final String? clearLogo;
|
|
final String? backgroundSquare;
|
|
@JsonKey(fromJson: flexibleBoolNullable)
|
|
final bool? skipChildren;
|
|
@JsonKey(fromJson: flexibleInt)
|
|
final int? flattenSeasons;
|
|
|
|
const PlexMetadataDto({
|
|
required this.ratingKey,
|
|
this.key,
|
|
this.guid,
|
|
this.studio,
|
|
this.type,
|
|
this.title,
|
|
this.titleSort,
|
|
this.contentRating,
|
|
this.summary,
|
|
this.rating,
|
|
this.audienceRating,
|
|
this.userRating,
|
|
this.year,
|
|
this.originallyAvailableAt,
|
|
this.thumb,
|
|
this.art,
|
|
this.duration,
|
|
this.addedAt,
|
|
this.updatedAt,
|
|
this.lastViewedAt,
|
|
this.grandparentTitle,
|
|
this.grandparentThumb,
|
|
this.grandparentArt,
|
|
this.grandparentRatingKey,
|
|
this.parentTitle,
|
|
this.parentThumb,
|
|
this.parentRatingKey,
|
|
this.parentIndex,
|
|
this.index,
|
|
this.grandparentTheme,
|
|
this.viewOffset,
|
|
this.viewCount,
|
|
this.leafCount,
|
|
this.viewedLeafCount,
|
|
this.childCount,
|
|
this.role,
|
|
this.mediaVersions,
|
|
this.genre,
|
|
this.director,
|
|
this.writer,
|
|
this.producer,
|
|
this.country,
|
|
this.collection,
|
|
this.label,
|
|
this.style,
|
|
this.mood,
|
|
this.audioLanguage,
|
|
this.subtitleLanguage,
|
|
this.subtitleMode,
|
|
this.playlistItemID,
|
|
this.playQueueItemID,
|
|
this.librarySectionID,
|
|
this.librarySectionTitle,
|
|
this.ratingImage,
|
|
this.audienceRatingImage,
|
|
this.tagline,
|
|
this.originalTitle,
|
|
this.editionTitle,
|
|
this.subtype,
|
|
this.extraType,
|
|
this.primaryExtraKey,
|
|
this.serverId,
|
|
this.serverName,
|
|
this.clearLogo,
|
|
this.backgroundSquare,
|
|
this.skipChildren,
|
|
this.flattenSeasons,
|
|
});
|
|
|
|
factory PlexMetadataDto.fromJson(Map<String, dynamic> rawJson) {
|
|
final json = kBlurArtwork ? _obfuscateJson(rawJson) : rawJson;
|
|
try {
|
|
return _$PlexMetadataDtoFromJson(json);
|
|
} on TypeError catch (e, st) {
|
|
Sentry.captureException(
|
|
e,
|
|
stackTrace: st,
|
|
withScope: (scope) {
|
|
scope.setContexts('json', json);
|
|
},
|
|
);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
factory PlexMetadataDto.fromJsonWithImages(Map<String, dynamic> json) {
|
|
String? clearLogoUrl;
|
|
String? backgroundSquareUrl;
|
|
final images = json['Image'] as List?;
|
|
if (images != null) {
|
|
for (final image in images) {
|
|
if (image is Map) {
|
|
final type = image['type'];
|
|
final url = image['url'] as String?;
|
|
if (url == null) continue;
|
|
if (type == 'clearLogo') clearLogoUrl = url;
|
|
if (type == 'backgroundSquare') backgroundSquareUrl = url;
|
|
}
|
|
}
|
|
}
|
|
if (clearLogoUrl == null && backgroundSquareUrl == null) {
|
|
return PlexMetadataDto.fromJson(json);
|
|
}
|
|
final enriched = Map<String, dynamic>.from(json);
|
|
if (clearLogoUrl != null) enriched['clearLogo'] = clearLogoUrl;
|
|
if (backgroundSquareUrl != null) enriched['backgroundSquare'] = backgroundSquareUrl;
|
|
return PlexMetadataDto.fromJson(enriched);
|
|
}
|
|
|
|
static Map<String, dynamic> _obfuscateJson(Map<String, dynamic> json) {
|
|
final copy = Map<String, dynamic>.from(json);
|
|
for (final key in const ['title', 'summary', 'tagline', 'grandparentTitle', 'parentTitle', 'studio']) {
|
|
if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String);
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), ratingKey) : ratingKey;
|
|
|
|
bool get isLibrarySection => key != null && key!.startsWith('/library/sections/');
|
|
|
|
bool get isUnmatched => guid == null || guid!.isEmpty || guid!.contains(_unmatchedAgentMarker);
|
|
|
|
/// Top-level scalar fields surface as a plain Plex JSON map. Used by the
|
|
/// download-manager cache layer to overlay scalar updates on top of an
|
|
/// existing Plex response without losing Chapter/Marker/Media arrays.
|
|
Map<String, dynamic> toJson() => _$PlexMetadataDtoToJson(this);
|
|
|
|
PlexMetadataDto copyWith({
|
|
String? ratingKey,
|
|
String? key,
|
|
String? guid,
|
|
String? studio,
|
|
String? type,
|
|
String? title,
|
|
String? titleSort,
|
|
String? contentRating,
|
|
String? summary,
|
|
double? rating,
|
|
double? audienceRating,
|
|
double? userRating,
|
|
int? year,
|
|
String? originallyAvailableAt,
|
|
String? thumb,
|
|
String? art,
|
|
int? duration,
|
|
int? addedAt,
|
|
int? updatedAt,
|
|
int? lastViewedAt,
|
|
String? grandparentTitle,
|
|
String? grandparentThumb,
|
|
String? grandparentArt,
|
|
String? grandparentRatingKey,
|
|
String? parentTitle,
|
|
String? parentThumb,
|
|
String? parentRatingKey,
|
|
int? parentIndex,
|
|
int? index,
|
|
String? grandparentTheme,
|
|
int? viewOffset,
|
|
int? viewCount,
|
|
int? leafCount,
|
|
int? viewedLeafCount,
|
|
int? childCount,
|
|
List<PlexRoleDto>? role,
|
|
List<PlexMediaVersionDto>? mediaVersions,
|
|
List<String>? genre,
|
|
List<String>? director,
|
|
List<String>? writer,
|
|
List<String>? producer,
|
|
List<String>? country,
|
|
List<String>? collection,
|
|
List<String>? label,
|
|
List<String>? style,
|
|
List<String>? mood,
|
|
String? audioLanguage,
|
|
String? subtitleLanguage,
|
|
int? subtitleMode,
|
|
int? playlistItemID,
|
|
int? playQueueItemID,
|
|
int? librarySectionID,
|
|
String? librarySectionTitle,
|
|
String? ratingImage,
|
|
String? audienceRatingImage,
|
|
String? tagline,
|
|
String? originalTitle,
|
|
String? editionTitle,
|
|
String? subtype,
|
|
int? extraType,
|
|
String? primaryExtraKey,
|
|
ServerId? serverId,
|
|
String? serverName,
|
|
String? clearLogo,
|
|
String? backgroundSquare,
|
|
bool? skipChildren,
|
|
int? flattenSeasons,
|
|
}) {
|
|
return PlexMetadataDto(
|
|
ratingKey: ratingKey ?? this.ratingKey,
|
|
key: key ?? this.key,
|
|
guid: guid ?? this.guid,
|
|
studio: studio ?? this.studio,
|
|
type: type ?? this.type,
|
|
title: title ?? this.title,
|
|
titleSort: titleSort ?? this.titleSort,
|
|
contentRating: contentRating ?? this.contentRating,
|
|
summary: summary ?? this.summary,
|
|
rating: rating ?? this.rating,
|
|
audienceRating: audienceRating ?? this.audienceRating,
|
|
userRating: userRating ?? this.userRating,
|
|
year: year ?? this.year,
|
|
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
|
|
thumb: thumb ?? this.thumb,
|
|
art: art ?? this.art,
|
|
duration: duration ?? this.duration,
|
|
addedAt: addedAt ?? this.addedAt,
|
|
updatedAt: updatedAt ?? this.updatedAt,
|
|
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
|
|
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
|
|
grandparentThumb: grandparentThumb ?? this.grandparentThumb,
|
|
grandparentArt: grandparentArt ?? this.grandparentArt,
|
|
grandparentRatingKey: grandparentRatingKey ?? this.grandparentRatingKey,
|
|
parentTitle: parentTitle ?? this.parentTitle,
|
|
parentThumb: parentThumb ?? this.parentThumb,
|
|
parentRatingKey: parentRatingKey ?? this.parentRatingKey,
|
|
parentIndex: parentIndex ?? this.parentIndex,
|
|
index: index ?? this.index,
|
|
grandparentTheme: grandparentTheme ?? this.grandparentTheme,
|
|
viewOffset: viewOffset ?? this.viewOffset,
|
|
viewCount: viewCount ?? this.viewCount,
|
|
leafCount: leafCount ?? this.leafCount,
|
|
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
|
|
childCount: childCount ?? this.childCount,
|
|
role: role ?? this.role,
|
|
mediaVersions: mediaVersions ?? this.mediaVersions,
|
|
genre: genre ?? this.genre,
|
|
director: director ?? this.director,
|
|
writer: writer ?? this.writer,
|
|
producer: producer ?? this.producer,
|
|
country: country ?? this.country,
|
|
collection: collection ?? this.collection,
|
|
label: label ?? this.label,
|
|
style: style ?? this.style,
|
|
mood: mood ?? this.mood,
|
|
audioLanguage: audioLanguage ?? this.audioLanguage,
|
|
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
|
|
subtitleMode: subtitleMode ?? this.subtitleMode,
|
|
playlistItemID: playlistItemID ?? this.playlistItemID,
|
|
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
|
|
librarySectionID: librarySectionID ?? this.librarySectionID,
|
|
librarySectionTitle: librarySectionTitle ?? this.librarySectionTitle,
|
|
ratingImage: ratingImage ?? this.ratingImage,
|
|
audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage,
|
|
tagline: tagline ?? this.tagline,
|
|
originalTitle: originalTitle ?? this.originalTitle,
|
|
editionTitle: editionTitle ?? this.editionTitle,
|
|
subtype: subtype ?? this.subtype,
|
|
extraType: extraType ?? this.extraType,
|
|
primaryExtraKey: primaryExtraKey ?? this.primaryExtraKey,
|
|
serverId: serverId ?? this.serverId,
|
|
serverName: serverName ?? this.serverName,
|
|
clearLogo: clearLogo ?? this.clearLogo,
|
|
backgroundSquare: backgroundSquare ?? this.backgroundSquare,
|
|
skipChildren: skipChildren ?? this.skipChildren,
|
|
flattenSeasons: flattenSeasons ?? this.flattenSeasons,
|
|
);
|
|
}
|
|
}
|
|
|
|
Map<String, Object?>? _rawMetadata(PlexMetadataDto dto) {
|
|
final raw = <String, Object?>{};
|
|
if (dto.key != null) raw['key'] = dto.key;
|
|
if (dto.skipChildren != null) raw['skipChildren'] = dto.skipChildren;
|
|
if (dto.flattenSeasons != null) raw['flattenSeasons'] = dto.flattenSeasons;
|
|
return raw.isEmpty ? null : raw;
|
|
}
|
|
|
|
/// Pure JSON/DTO→neutral-type mappers for Plex. Mirrors [JellyfinMappers].
|
|
///
|
|
/// Methods come in two flavours:
|
|
/// * `<type>FromJson` — accept raw Plex JSON and parse + map in one step.
|
|
/// Used by tests and by callers that haven't already parsed a DTO.
|
|
/// * `<type>` (DTO-typed) — accept an already-parsed DTO. Used by the
|
|
/// [PlexClient] which keeps a DTO step internally for caching, copying,
|
|
/// and OnDeck composition.
|
|
///
|
|
/// Pure: no HTTP, no client state, no token-aware image-URL resolution.
|
|
/// Token-aware image URLs are layered on at the [PlexClient] boundary via
|
|
/// `thumbnailUrl`/`externalImageUrl` — this layer leaves the relative
|
|
/// `thumb`/`art`/`clearLogo` paths intact so they can be resolved per
|
|
/// instance.
|
|
class PlexMappers {
|
|
PlexMappers._();
|
|
|
|
/// Map a Plex `Metadata` JSON entry directly into a [PlexMediaItem].
|
|
static PlexMediaItem mediaItemFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
|
|
final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId, serverName: serverName);
|
|
return mediaItem(dto);
|
|
}
|
|
|
|
/// Parse a Plex `/library/metadata/{id}` JSON object into a neutral
|
|
/// [MediaItem]. Used by the offline cache layer to convert persisted Plex
|
|
/// JSON back into MediaItem without depending on the Plex client surface.
|
|
static MediaItem mediaItemFromCacheJson(Map<String, dynamic> json, {required ServerId serverId}) {
|
|
final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId);
|
|
return mediaItem(dto);
|
|
}
|
|
|
|
/// Map a parsed [PlexMetadataDto] into a [PlexMediaItem].
|
|
static PlexMediaItem mediaItem(PlexMetadataDto dto) {
|
|
return PlexMediaItem(
|
|
id: dto.ratingKey,
|
|
kind: MediaKind.fromString(dto.type),
|
|
guid: dto.guid,
|
|
title: dto.title,
|
|
titleSort: dto.titleSort,
|
|
summary: dto.summary,
|
|
tagline: dto.tagline,
|
|
originalTitle: dto.originalTitle,
|
|
editionTitle: dto.editionTitle,
|
|
studio: dto.studio,
|
|
year: dto.year,
|
|
originallyAvailableAt: dto.originallyAvailableAt,
|
|
contentRating: dto.contentRating,
|
|
parentId: dto.parentRatingKey,
|
|
parentTitle: dto.parentTitle,
|
|
parentThumbPath: dto.parentThumb,
|
|
parentIndex: dto.parentIndex,
|
|
index: dto.index,
|
|
grandparentId: dto.grandparentRatingKey,
|
|
grandparentTitle: dto.grandparentTitle,
|
|
grandparentThumbPath: dto.grandparentThumb,
|
|
grandparentArtPath: dto.grandparentArt,
|
|
thumbPath: dto.thumb,
|
|
artPath: dto.art,
|
|
clearLogoPath: dto.clearLogo,
|
|
backgroundSquarePath: dto.backgroundSquare,
|
|
durationMs: dto.duration,
|
|
viewOffsetMs: dto.viewOffset,
|
|
viewCount: dto.viewCount,
|
|
lastViewedAt: dto.lastViewedAt,
|
|
leafCount: dto.leafCount,
|
|
viewedLeafCount: dto.viewedLeafCount,
|
|
childCount: dto.childCount,
|
|
addedAt: dto.addedAt,
|
|
updatedAt: dto.updatedAt,
|
|
rating: dto.rating,
|
|
audienceRating: dto.audienceRating,
|
|
userRating: dto.userRating,
|
|
ratingImage: dto.ratingImage,
|
|
audienceRatingImage: dto.audienceRatingImage,
|
|
genres: dto.genre,
|
|
directors: dto.director,
|
|
writers: dto.writer,
|
|
producers: dto.producer,
|
|
countries: dto.country,
|
|
collections: dto.collection,
|
|
labels: dto.label,
|
|
styles: dto.style,
|
|
moods: dto.mood,
|
|
roles: dto.role?.map(role).toList(),
|
|
mediaVersions: dto.mediaVersions?.map(mediaVersion).toList(),
|
|
libraryId: dto.librarySectionID?.toString(),
|
|
libraryTitle: dto.librarySectionTitle,
|
|
audioLanguage: dto.audioLanguage,
|
|
subtitleLanguage: dto.subtitleLanguage,
|
|
subtitleMode: dto.subtitleMode,
|
|
trailerKey: dto.primaryExtraKey,
|
|
playlistItemId: dto.playlistItemID,
|
|
playQueueItemId: dto.playQueueItemID,
|
|
subtype: dto.subtype,
|
|
extraType: dto.extraType,
|
|
serverId: dto.serverId,
|
|
serverName: dto.serverName,
|
|
raw: _rawMetadata(dto),
|
|
);
|
|
}
|
|
|
|
/// Map a parsed [PlexRoleDto] into a [MediaRole].
|
|
static MediaRole role(PlexRoleDto dto) {
|
|
return MediaRole(id: dto.id?.toString(), tag: dto.tag, role: dto.role, thumbPath: dto.thumb);
|
|
}
|
|
|
|
/// Map a parsed [PlexMediaVersionDto] into a [MediaVersion].
|
|
static MediaVersion mediaVersion(PlexMediaVersionDto dto) {
|
|
final part = MediaPart(
|
|
id: dto.id.toString(),
|
|
streamPath: dto.partKey,
|
|
container: dto.container,
|
|
accessible: dto.accessible,
|
|
exists: dto.exists,
|
|
);
|
|
return MediaVersion(
|
|
id: dto.id.toString(),
|
|
width: dto.width,
|
|
height: dto.height,
|
|
videoResolution: dto.videoResolution,
|
|
videoCodec: dto.videoCodec,
|
|
bitrate: dto.bitrate,
|
|
container: dto.container,
|
|
parts: [part],
|
|
);
|
|
}
|
|
|
|
/// Map a Plex Media JSON entry directly into a [MediaVersion].
|
|
static MediaVersion mediaVersionFromJson(Map<String, dynamic> json) {
|
|
final dto = PlexMediaVersionDto.fromJson(json);
|
|
final parts = _mediaPartsFromJson(json['Part'], fallbackId: dto.id.toString(), fallbackContainer: dto.container);
|
|
if (parts.isEmpty) return mediaVersion(dto);
|
|
return MediaVersion(
|
|
id: dto.id.toString(),
|
|
width: dto.width,
|
|
height: dto.height,
|
|
videoResolution: dto.videoResolution,
|
|
videoCodec: dto.videoCodec,
|
|
bitrate: dto.bitrate,
|
|
container: dto.container,
|
|
parts: parts,
|
|
);
|
|
}
|
|
|
|
static MediaDisplayCriteria? displayCriteriaFromJson(Map<String, dynamic>? media, Map<String, dynamic>? videoStream) {
|
|
if (videoStream == null) return null;
|
|
|
|
final doviProfile = flexibleInt(videoStream['DOVIProfile']);
|
|
final doviCompatibilityId = flexibleInt(videoStream['DOVIBLCompatID']);
|
|
final hasDolbyVision = (doviProfile != null && doviProfile > 0) || flexibleBool(videoStream['DOVIPresent']);
|
|
final transfer = _stringOrNull(videoStream['colorTrc']);
|
|
final primaries = _stringOrNull(videoStream['colorPrimaries']);
|
|
final matrix = _stringOrNull(videoStream['colorSpace']);
|
|
final defaults = _defaultDisplayColorTags(
|
|
isDolbyVision: hasDolbyVision,
|
|
doviCompatibilityId: doviCompatibilityId,
|
|
transfer: transfer,
|
|
primaries: primaries,
|
|
matrix: matrix,
|
|
);
|
|
final criteria = MediaDisplayCriteria.fromRaw(
|
|
fps: videoStream['frameRate'],
|
|
width: videoStream['width'] ?? media?['width'],
|
|
height: videoStream['height'] ?? media?['height'],
|
|
doviProfile: doviProfile,
|
|
doviLevel: videoStream['DOVILevel'],
|
|
doviCompatibilityId: doviCompatibilityId,
|
|
transfer: transfer ?? defaults.transfer,
|
|
primaries: primaries ?? defaults.primaries,
|
|
matrix: matrix ?? defaults.matrix,
|
|
);
|
|
return criteria.isUsable ? criteria : null;
|
|
}
|
|
|
|
static ({String? transfer, String? primaries, String? matrix}) _defaultDisplayColorTags({
|
|
required bool isDolbyVision,
|
|
int? doviCompatibilityId,
|
|
String? transfer,
|
|
String? primaries,
|
|
String? matrix,
|
|
}) {
|
|
final colorTags = _normalizedDisplayColorTags(transfer, primaries, matrix);
|
|
if (doviCompatibilityId == 4 || colorTags.contains('hlg') || colorTags.contains('arib')) {
|
|
return (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc');
|
|
}
|
|
if (doviCompatibilityId == 1 ||
|
|
doviCompatibilityId == 6 ||
|
|
colorTags.contains('smpte2084') ||
|
|
colorTags.contains('st2084') ||
|
|
colorTags.contains('pq') ||
|
|
colorTags.contains('bt2020')) {
|
|
return (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc');
|
|
}
|
|
if (doviCompatibilityId == 2 || !isDolbyVision) {
|
|
return (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709');
|
|
}
|
|
return (transfer: null, primaries: null, matrix: null);
|
|
}
|
|
|
|
/// Map a parsed [PlexLibraryDto] into a [MediaLibrary].
|
|
static MediaLibrary mediaLibrary(PlexLibraryDto dto) {
|
|
return MediaLibrary(
|
|
id: dto.key,
|
|
backend: MediaBackend.plex,
|
|
title: dto.title,
|
|
kind: MediaKind.fromString(dto.type),
|
|
language: dto.language,
|
|
updatedAt: dto.updatedAt,
|
|
createdAt: dto.createdAt,
|
|
hidden: dto.hidden == 1,
|
|
isShared: dto.isShared,
|
|
serverId: dto.serverId,
|
|
serverName: dto.serverName,
|
|
);
|
|
}
|
|
|
|
/// Map a Plex `/library/sections` Directory entry into a [MediaLibrary].
|
|
static MediaLibrary mediaLibraryFromJson(
|
|
Map<String, dynamic> json, {
|
|
ServerId? serverId,
|
|
String? serverName,
|
|
bool isShared = false,
|
|
}) {
|
|
final dto = PlexLibraryDto.fromJson(
|
|
json,
|
|
).copyWith(serverId: serverIdOrNull(serverId), serverName: serverName, isShared: isShared);
|
|
return mediaLibrary(dto);
|
|
}
|
|
|
|
/// Map a parsed [PlexHubDto] into a [MediaHub].
|
|
static MediaHub mediaHub(PlexHubDto dto) {
|
|
return MediaHub(
|
|
id: dto.hubKey,
|
|
identifier: dto.hubIdentifier,
|
|
title: dto.title,
|
|
type: dto.type,
|
|
items: dto.items.map(mediaItem).toList(),
|
|
size: dto.size,
|
|
more: dto.more,
|
|
serverId: dto.serverId,
|
|
serverName: dto.serverName,
|
|
);
|
|
}
|
|
|
|
/// Map a Plex `/hubs` Hub JSON entry directly into a [MediaHub].
|
|
static MediaHub mediaHubFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
|
|
return mediaHub(PlexHubDto.fromJson(json, serverId: serverId, serverName: serverName));
|
|
}
|
|
|
|
/// Map a parsed [PlexPlaylistDto] into a [MediaPlaylist].
|
|
static MediaPlaylist mediaPlaylist(PlexPlaylistDto dto) {
|
|
return MediaPlaylist(
|
|
id: dto.ratingKey,
|
|
backend: MediaBackend.plex,
|
|
title: dto.title,
|
|
summary: dto.summary,
|
|
guid: dto.guid,
|
|
smart: dto.smart,
|
|
playlistType: dto.playlistType,
|
|
durationMs: dto.duration,
|
|
leafCount: dto.leafCount,
|
|
viewCount: dto.viewCount,
|
|
addedAt: dto.addedAt,
|
|
updatedAt: dto.updatedAt,
|
|
lastViewedAt: dto.lastViewedAt,
|
|
compositeImagePath: dto.composite,
|
|
thumbPath: dto.thumb,
|
|
serverId: dto.serverId,
|
|
serverName: dto.serverName,
|
|
);
|
|
}
|
|
|
|
/// Map a Plex `/playlists` Metadata entry directly into a [MediaPlaylist].
|
|
static MediaPlaylist mediaPlaylistFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
|
|
final dto = PlexPlaylistDto.fromJson(json).copyWith(serverId: serverId, serverName: serverName);
|
|
return mediaPlaylist(dto);
|
|
}
|
|
}
|
|
|
|
/// Build a [MediaSourceInfo] from a Plex `/library/metadata/{id}` JSON
|
|
/// envelope as stored by [PlexApiCache]. Parses audio/subtitle tracks from
|
|
/// `Media[0].Part[0].Stream[]` so that offline playback can still apply
|
|
/// language-based track selection.
|
|
///
|
|
/// Returns `null` when the JSON shape is missing the `Media`/`Part` arrays.
|
|
/// Plex-only — the on-disk format mirrors what the Plex API returns and
|
|
/// uses Plex `streamType` int codes (1=video, 2=audio, 3=subtitle).
|
|
MediaSourceInfo? plexMediaSourceInfoFromCacheJson(Map<String, dynamic> metadata, {int mediaIndex = 0}) {
|
|
final media = flexibleList(metadata['Media']);
|
|
if (media == null || media.isEmpty) return null;
|
|
final selectedMedia = mediaIndex >= 0 && mediaIndex < media.length ? media[mediaIndex] : media.first;
|
|
final parts = flexibleList(selectedMedia['Part']);
|
|
if (parts == null || parts.isEmpty) return null;
|
|
final streams = walkStreams(
|
|
flexibleList(parts.first['Stream']),
|
|
const PlexFileInfoStreamReader(),
|
|
onMalformed: (error, _, _) => appLogger.d('Skipping malformed stream in cached metadata', error: error),
|
|
);
|
|
|
|
return MediaSourceInfo(
|
|
videoUrl: '',
|
|
audioTracks: streams.audioTracks,
|
|
subtitleTracks: streams.subtitleTracks,
|
|
chapters: const [],
|
|
displayCriteria: PlexMappers.displayCriteriaFromJson(
|
|
selectedMedia is Map<String, dynamic> ? selectedMedia : null,
|
|
streams.videoStream,
|
|
),
|
|
);
|
|
}
|
|
|
|
PlaybackExtras plexPlaybackExtrasFromCacheJson(
|
|
Map<String, dynamic>? metadataJson, {
|
|
String? introPattern,
|
|
String? creditsPattern,
|
|
bool forceChapterFallback = false,
|
|
}) {
|
|
return PlaybackExtras.withChapterFallback(
|
|
chapters: plexChaptersFromCacheJson(metadataJson),
|
|
markers: plexMarkersFromCacheJson(metadataJson),
|
|
introPatternStr: introPattern,
|
|
creditsPatternStr: creditsPattern,
|
|
forceChapterFallback: forceChapterFallback,
|
|
);
|
|
}
|
|
|
|
List<MediaChapter> plexChaptersFromCacheJson(Map<String, dynamic>? metadataJson) {
|
|
final chapterList = metadataJson?['Chapter'];
|
|
if (chapterList is! List) return const [];
|
|
|
|
final out = <MediaChapter>[];
|
|
for (final chapter in chapterList.whereType<Map<String, dynamic>>()) {
|
|
final id = flexibleInt(chapter['id']);
|
|
if (id == null) continue;
|
|
out.add(
|
|
MediaChapter(
|
|
id: id,
|
|
index: flexibleInt(chapter['index']),
|
|
startTimeOffset: flexibleInt(chapter['startTimeOffset']),
|
|
endTimeOffset: flexibleInt(chapter['endTimeOffset']),
|
|
title: chapter['tag']?.toString() ?? chapter['title']?.toString(),
|
|
thumb: chapter['thumb'] as String?,
|
|
),
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
List<MediaMarker> plexMarkersFromCacheJson(Map<String, dynamic>? metadataJson) {
|
|
final markerList = metadataJson?['Marker'];
|
|
if (markerList is! List) return const [];
|
|
|
|
final out = <MediaMarker>[];
|
|
for (final marker in markerList.whereType<Map<String, dynamic>>()) {
|
|
final id = flexibleInt(marker['id']);
|
|
final type = marker['type']?.toString();
|
|
final start = flexibleInt(marker['startTimeOffset']);
|
|
final end = flexibleInt(marker['endTimeOffset']);
|
|
if (id == null || type == null || start == null || end == null) continue;
|
|
out.add(MediaMarker(id: id, type: type, startTimeOffset: start, endTimeOffset: end));
|
|
}
|
|
return out;
|
|
}
|