import 'package:json_annotation/json_annotation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../services/settings_service.dart' show EpisodePosterMode; import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; import 'mixins/multi_server_fields.dart'; import 'plex_media_version.dart'; import 'plex_role.dart'; import '../utils/global_key_utils.dart'; import '../utils/json_utils.dart'; part 'plex_metadata.g.dart'; Object? _readRatingKey(Map json, String key) => json['ratingKey'] ?? json['key'] ?? ''; List? _tagsFromJson(List? json) => json?.cast>().map((e) => e['tag'] as String).toList(); /// Media type enum for type-safe media type handling enum PlexMediaType { movie, show, season, episode, artist, album, track, collection, playlist, clip, photo, unknown; /// Whether this type represents video content bool get isVideo => this == movie || this == episode || this == clip; /// Whether this type is part of a show hierarchy bool get isShowRelated => this == show || this == season || this == episode; /// Whether this type represents music content bool get isMusic => this == artist || this == album || this == track; /// Whether this type can be played directly bool get isPlayable => isVideo || this == track; /// Plex API type number for metadata editing endpoints int get typeNumber => switch (this) { PlexMediaType.movie => 1, PlexMediaType.show => 2, PlexMediaType.season => 3, PlexMediaType.episode => 4, PlexMediaType.artist => 8, PlexMediaType.album => 9, PlexMediaType.track => 10, _ => 0, }; } @JsonSerializable() class PlexMetadata with MultiServerFields { @JsonKey(readValue: _readRatingKey) 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; final int? year; final String? originallyAvailableAt; // Full release date (YYYY-MM-DD) final String? thumb; final String? art; final int? duration; final int? addedAt; final int? updatedAt; final int? lastViewedAt; // Timestamp when item was last viewed final String? grandparentTitle; // Show title for episodes final String? grandparentThumb; // Show poster for episodes final String? grandparentArt; // Show art for episodes final String? grandparentRatingKey; // Show rating key for episodes final String? parentTitle; // Season title for episodes final String? parentThumb; // Season poster for episodes final String? parentRatingKey; // Season rating key for episodes final int? parentIndex; // Season number final int? index; // Episode number final String? grandparentTheme; // Show theme music final int? viewOffset; // Resume position in ms final int? viewCount; final int? leafCount; // Total number of episodes in a series/season final int? viewedLeafCount; // Number of watched episodes in a series/season @JsonKey(fromJson: flexibleInt) final int? childCount; // Number of items in a collection or playlist @JsonKey(name: 'Role') final List? role; // Cast members @JsonKey(name: 'Media', includeToJson: false) final List? mediaVersions; // Available media versions/editions @JsonKey(name: 'Genre', fromJson: _tagsFromJson, includeToJson: false) final List? genre; @JsonKey(name: 'Director', fromJson: _tagsFromJson, includeToJson: false) final List? director; @JsonKey(name: 'Writer', fromJson: _tagsFromJson, includeToJson: false) final List? writer; @JsonKey(name: 'Producer', fromJson: _tagsFromJson, includeToJson: false) final List? producer; @JsonKey(name: 'Country', fromJson: _tagsFromJson, includeToJson: false) final List? country; @JsonKey(name: 'Collection', fromJson: _tagsFromJson, includeToJson: false) final List? collection; @JsonKey(name: 'Label', fromJson: _tagsFromJson, includeToJson: false) final List? label; @JsonKey(name: 'Style', fromJson: _tagsFromJson, includeToJson: false) final List? style; @JsonKey(name: 'Mood', fromJson: _tagsFromJson, includeToJson: false) final List? mood; final String? audioLanguage; // Per-media preferred audio language final String? subtitleLanguage; // Per-media preferred subtitle language @JsonKey(fromJson: flexibleInt) final int? subtitleMode; // Per-media subtitle mode (0=manual, 1=foreign audio, 2=always, -1=account default) final int? playlistItemID; // Playlist item ID (for dumb playlists only) final int? playQueueItemID; // Play queue item ID (unique even for duplicates) final int? librarySectionID; // Library section ID this item belongs to final String? librarySectionTitle; // Library section title this item belongs to final String? ratingImage; // Rating source URI (e.g. rottentomatoes://image.rating.ripe) final String? audienceRatingImage; // Audience rating source URI final String? tagline; final String? originalTitle; final String? editionTitle; // Edition name for movies (e.g., "Director's Cut", "Extended") final String? subtype; // Clip subtype: "trailer", "behindTheScenes", "deleted", etc. final int? extraType; // Numeric extra type identifier final String? primaryExtraKey; // Points to main trailer (e.g., "/library/metadata/52601") // Multi-server support fields (from MultiServerFields mixin) @override @JsonKey(includeFromJson: false, includeToJson: false) final String? serverId; @override @JsonKey(includeFromJson: false, includeToJson: false) final String? serverName; // Clear logo URL (extracted from Image array, but serialized for offline storage) final String? clearLogo; // Square background art URL (extracted from Image array, used for near-square hero layouts) final String? backgroundSquare; /// Global unique identifier across all servers (serverId:ratingKey) String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey; /// Whether this item represents a library section (shared whole-library, not a media item). /// These have keys like `/library/sections/5/all` instead of `/library/metadata/12345`. bool get isLibrarySection => key != null && key!.startsWith('/library/sections/'); /// Extract the library section ID from a library-section item's key. /// Returns null if this is not a library section item. String? get librarySectionKey { if (!isLibrarySection) return null; final match = RegExp(r'/library/sections/(\d+)').firstMatch(key!); return match?.group(1); } /// Parsed media type enum for type-safe comparisons PlexMediaType get mediaType { if (type == null) return PlexMediaType.unknown; return switch (type!.toLowerCase()) { 'movie' => PlexMediaType.movie, 'show' => PlexMediaType.show, 'season' => PlexMediaType.season, 'episode' => PlexMediaType.episode, 'artist' => PlexMediaType.artist, 'album' => PlexMediaType.album, 'track' => PlexMediaType.track, 'collection' => PlexMediaType.collection, 'playlist' => PlexMediaType.playlist, 'clip' => PlexMediaType.clip, 'photo' => PlexMediaType.photo, _ => PlexMediaType.unknown, }; } PlexMetadata({ 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, }); /// Create a copy of this metadata with optional field overrides PlexMetadata 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? role, List? mediaVersions, List? genre, List? director, List? writer, List? producer, List? country, List? collection, List? label, List? style, List? 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, String? serverId, String? serverName, String? clearLogo, String? backgroundSquare, }) { return PlexMetadata( 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, ); } /// Extract an image URL by type from the Image array in raw JSON static String? _extractImageFromJson(Map json, String imageType) { if (!json.containsKey('Image')) return null; final images = json['Image'] as List?; if (images == null) return null; for (var image in images) { if (image is Map && image['type'] == imageType) { return image['url'] as String?; } } return null; } /// Create from JSON with Image array fields extracted factory PlexMetadata.fromJsonWithImages(Map json) { final clearLogoUrl = _extractImageFromJson(json, 'clearLogo'); if (clearLogoUrl != null) { json['clearLogo'] = clearLogoUrl; } final backgroundSquareUrl = _extractImageFromJson(json, 'backgroundSquare'); if (backgroundSquareUrl != null) { json['backgroundSquare'] = backgroundSquareUrl; } return PlexMetadata.fromJson(json); } /// Returns the best hero art path based on the container's aspect ratio. /// Uses backgroundSquare when the container is closer to 1:1 than 16:9. String? heroArt({required double containerAspectRatio}) { // Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39 if (containerAspectRatio < 1.39 && backgroundSquare != null) { return backgroundSquare; } return art; } // Helper to get the display title (show name for episodes/seasons, title otherwise) String get displayTitle { final itemType = type?.toLowerCase(); // For episodes and seasons, prefer grandparent title (show name) if ((itemType == 'episode' || itemType == 'season') && grandparentTitle != null) { return grandparentTitle!; } // For seasons without grandparent, check if this IS the show (parentTitle might have show name) if (itemType == 'season' && parentTitle != null) { return parentTitle!; } return title ?? ''; } // Helper to get the subtitle (episode/season title) String? get displaySubtitle { final itemType = type?.toLowerCase(); if (itemType == 'episode' || itemType == 'season') { // If we showed grandparent/parent as title, show this item's title as subtitle if (grandparentTitle != null || (itemType == 'season' && parentTitle != null)) { return title; } } return null; } /// Returns the appropriate image path based on episode poster mode. /// For episodes: /// - seriesPoster: grandparentThumb (series poster) /// - seasonPoster: parentThumb (season poster) /// - episodeThumbnail: thumb (16:9 episode still) /// For seasons: returns grandparentThumb (series poster), or art/thumb in mixed hub context /// For movies/shows/seasons in mixed hub context: returns art (16:9 background) /// For other types: returns thumb String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { final itemType = type?.toLowerCase(); if (itemType == 'episode') { switch (mode) { case EpisodePosterMode.episodeThumbnail: return thumb; // 16:9 episode thumbnail case EpisodePosterMode.seasonPoster: return parentThumb ?? grandparentThumb ?? thumb; case EpisodePosterMode.seriesPoster: return grandparentThumb ?? thumb; } } else if (itemType == 'season') { // In mixed hub with episode thumbnail mode, use art/thumb (16:9) if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail) { return art ?? thumb; } // Otherwise use series poster (2:3) if (grandparentThumb != null) { return grandparentThumb!; } } // For movies/shows in mixed hub context with episode thumbnail mode, use art (16:9) if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && (itemType == 'movie' || itemType == 'show')) { return art ?? thumb; } return thumb; } /// Returns true if this item should use 16:9 aspect ratio. /// Episodes use 16:9 when in episodeThumbnail mode. /// Clips (trailers, extras) always use 16:9. /// Movies, shows, and seasons use 16:9 in mixed hub context with episodeThumbnail mode. bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) { final itemType = type?.toLowerCase(); // Clips (trailers, extras) are always 16:9 if (itemType == 'clip') return true; if (itemType == 'episode' && mode == EpisodePosterMode.episodeThumbnail) { return true; } // Movies, shows, and seasons use 16:9 in mixed hubs with episode thumbnail mode if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && (itemType == 'movie' || itemType == 'show' || itemType == 'season')) { return true; } return false; } /// Returns true if this item has started but not finished playback /// Only applicable for individual items (movies, episodes) bool get hasActiveProgress { if (duration == null || viewOffset == null) return false; return viewOffset! > 0 && viewOffset! < duration!; } // Helper to determine if content is watched bool get isWatched { // For series/seasons, check if all episodes are watched if (leafCount != null && viewedLeafCount != null) { return viewedLeafCount! >= leafCount!; } // For individual items (movies, episodes), check viewCount return viewCount != null && viewCount! > 0; } factory PlexMetadata.fromJson(Map json) { try { return _$PlexMetadataFromJson(kBlurArtwork ? _obfuscateJson(json) : json); } on TypeError catch (e, st) { Sentry.captureException(e, stackTrace: st, withScope: (scope) { scope.setContexts('json', json); }); rethrow; } } static Map _obfuscateJson(Map json) { final copy = Map.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; } Map toJson() => _$PlexMetadataToJson(this); }