Files
plezy/lib/models/plex_metadata.dart
T

464 lines
16 KiB
Dart
Raw Normal View History

2025-10-18 00:05:20 +02:00
import 'package:json_annotation/json_annotation.dart';
2026-01-08 14:42:49 +01:00
import '../services/settings_service.dart' show EpisodePosterMode;
import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText;
import 'mixins/multi_server_fields.dart';
2025-11-09 15:57:33 +01:00
import 'plex_role.dart';
import '../utils/global_key_utils.dart';
2025-11-09 15:57:33 +01:00
2025-10-18 00:05:20 +02:00
part 'plex_metadata.g.dart';
2025-12-13 00:16:50 +01:00
/// 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;
2026-02-22 17:56:13 +01:00
/// 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,
};
2025-12-13 00:16:50 +01:00
}
2025-10-18 00:05:20 +02:00
@JsonSerializable()
class PlexMetadata with MultiServerFields {
2025-10-18 00:05:20 +02:00
final String ratingKey;
final String key;
final String? guid;
final String? studio;
final String type;
final String title;
2026-02-05 10:15:59 +01:00
final String? titleSort;
2025-10-18 00:05:20 +02:00
final String? contentRating;
final String? summary;
final double? rating;
2025-11-09 15:50:59 +01:00
final double? audienceRating;
2026-02-20 09:56:57 +01:00
final double? userRating;
2025-10-18 00:05:20 +02:00
final int? year;
final String? originallyAvailableAt; // Full release date (YYYY-MM-DD)
2025-10-18 00:05:20 +02:00
final String? thumb;
final String? art;
final int? duration;
final int? addedAt;
final int? updatedAt;
2025-11-22 05:58:08 +01:00
final int? lastViewedAt; // Timestamp when item was last viewed
2025-10-18 00:05:20 +02:00
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
2025-11-04 04:17:39 +01:00
final String? parentThumb; // Season poster for episodes
2025-10-18 00:05:20 +02:00
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
2025-11-16 20:53:59 +01:00
final int? childCount; // Number of items in a collection or playlist
2025-11-09 15:57:33 +01:00
@JsonKey(name: 'Role')
final List<PlexRole>? role; // Cast members
2025-11-14 22:27:12 +01:00
final String? audioLanguage; // Per-media preferred audio language
final String? subtitleLanguage; // Per-media preferred subtitle language
2025-11-15 04:07:08 +01:00
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
2025-11-16 20:53:59 +01:00
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
2026-02-08 16:03:23 +01:00
final String? ratingImage; // Rating source URI (e.g. rottentomatoes://image.rating.ripe)
final String? audienceRatingImage; // Audience rating source URI
2026-02-22 17:56:13 +01:00
final String? tagline;
final String? originalTitle;
2026-02-14 23:09:48 -05:00
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")
2025-10-18 00:05:20 +02:00
// Multi-server support fields (from MultiServerFields mixin)
@override
2025-11-21 23:59:39 +01:00
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId;
@override
2025-11-21 23:59:39 +01:00
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName;
2025-11-21 23:59:39 +01:00
2025-12-11 19:15:06 +01:00
// Clear logo URL (extracted from Image array, but serialized for offline storage)
final String? clearLogo;
2025-10-18 00:05:20 +02:00
2026-02-26 13:00:55 +01:00
// Square background art URL (extracted from Image array, used for near-square hero layouts)
final String? backgroundSquare;
2025-11-21 23:59:39 +01:00
/// Global unique identifier across all servers (serverId:ratingKey)
String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey;
2025-11-21 23:59:39 +01:00
2025-12-13 00:16:50 +01:00
/// Parsed media type enum for type-safe comparisons
PlexMediaType get mediaType {
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,
};
}
2025-10-18 00:05:20 +02:00
PlexMetadata({
required this.ratingKey,
required this.key,
this.guid,
this.studio,
required this.type,
required this.title,
2026-02-05 10:15:59 +01:00
this.titleSort,
2025-10-18 00:05:20 +02:00
this.contentRating,
this.summary,
this.rating,
2025-11-09 15:50:59 +01:00
this.audienceRating,
2026-02-20 09:56:57 +01:00
this.userRating,
2025-10-18 00:05:20 +02:00
this.year,
this.originallyAvailableAt,
2025-10-18 00:05:20 +02:00
this.thumb,
this.art,
this.duration,
this.addedAt,
this.updatedAt,
2025-11-22 05:58:08 +01:00
this.lastViewedAt,
2025-10-18 00:05:20 +02:00
this.grandparentTitle,
this.grandparentThumb,
this.grandparentArt,
this.grandparentRatingKey,
this.parentTitle,
2025-11-04 04:17:39 +01:00
this.parentThumb,
2025-10-18 00:05:20 +02:00
this.parentRatingKey,
this.parentIndex,
this.index,
this.grandparentTheme,
this.viewOffset,
this.viewCount,
this.leafCount,
this.viewedLeafCount,
2025-11-16 20:53:59 +01:00
this.childCount,
2025-11-09 15:57:33 +01:00
this.role,
2025-11-14 22:27:12 +01:00
this.audioLanguage,
this.subtitleLanguage,
2025-11-15 04:07:08 +01:00
this.playlistItemID,
2025-11-16 20:53:59 +01:00
this.playQueueItemID,
this.librarySectionID,
this.librarySectionTitle,
2026-02-08 16:03:23 +01:00
this.ratingImage,
this.audienceRatingImage,
2026-02-22 17:56:13 +01:00
this.tagline,
this.originalTitle,
2026-02-14 23:09:48 -05:00
this.subtype,
this.extraType,
this.primaryExtraKey,
2025-11-21 23:59:39 +01:00
this.serverId,
this.serverName,
2025-12-11 19:15:06 +01:00
this.clearLogo,
2026-02-26 13:00:55 +01:00
this.backgroundSquare,
2025-10-18 00:05:20 +02:00
});
2025-11-04 10:28:37 +01:00
/// Create a copy of this metadata with optional field overrides
PlexMetadata copyWith({
String? ratingKey,
String? key,
String? guid,
String? studio,
String? type,
String? title,
2026-02-05 10:15:59 +01:00
String? titleSort,
2025-11-04 10:28:37 +01:00
String? contentRating,
String? summary,
double? rating,
2025-11-09 15:50:59 +01:00
double? audienceRating,
2026-02-20 09:56:57 +01:00
double? userRating,
2025-11-04 10:28:37 +01:00
int? year,
String? originallyAvailableAt,
2025-11-04 10:28:37 +01:00
String? thumb,
String? art,
int? duration,
int? addedAt,
int? updatedAt,
2025-11-22 05:58:08 +01:00
int? lastViewedAt,
2025-11-04 10:28:37 +01:00
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,
2025-11-16 20:53:59 +01:00
int? childCount,
2025-11-09 15:57:33 +01:00
List<PlexRole>? role,
2025-11-14 22:27:12 +01:00
String? audioLanguage,
String? subtitleLanguage,
2025-11-15 04:07:08 +01:00
int? playlistItemID,
2025-11-16 20:53:59 +01:00
int? playQueueItemID,
int? librarySectionID,
String? librarySectionTitle,
2026-02-08 16:03:23 +01:00
String? ratingImage,
String? audienceRatingImage,
2026-02-22 17:56:13 +01:00
String? tagline,
String? originalTitle,
2026-02-14 23:09:48 -05:00
String? subtype,
int? extraType,
String? primaryExtraKey,
2025-11-21 23:59:39 +01:00
String? serverId,
String? serverName,
2025-12-11 19:15:06 +01:00
String? clearLogo,
2026-02-26 13:00:55 +01:00
String? backgroundSquare,
2025-11-04 10:28:37 +01:00
}) {
2025-12-11 19:15:06 +01:00
return PlexMetadata(
2025-11-04 10:28:37 +01:00
ratingKey: ratingKey ?? this.ratingKey,
key: key ?? this.key,
guid: guid ?? this.guid,
studio: studio ?? this.studio,
type: type ?? this.type,
title: title ?? this.title,
2026-02-05 10:15:59 +01:00
titleSort: titleSort ?? this.titleSort,
2025-11-04 10:28:37 +01:00
contentRating: contentRating ?? this.contentRating,
summary: summary ?? this.summary,
rating: rating ?? this.rating,
2025-11-09 15:50:59 +01:00
audienceRating: audienceRating ?? this.audienceRating,
2026-02-20 09:56:57 +01:00
userRating: userRating ?? this.userRating,
2025-11-04 10:28:37 +01:00
year: year ?? this.year,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
2025-11-04 10:28:37 +01:00
thumb: thumb ?? this.thumb,
art: art ?? this.art,
duration: duration ?? this.duration,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
2025-11-22 05:58:08 +01:00
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
2025-11-04 10:28:37 +01:00
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,
2025-11-16 20:53:59 +01:00
childCount: childCount ?? this.childCount,
2025-11-09 15:57:33 +01:00
role: role ?? this.role,
2025-11-14 22:27:12 +01:00
audioLanguage: audioLanguage ?? this.audioLanguage,
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
2025-11-15 04:07:08 +01:00
playlistItemID: playlistItemID ?? this.playlistItemID,
2025-11-16 20:53:59 +01:00
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
librarySectionID: librarySectionID ?? this.librarySectionID,
librarySectionTitle: librarySectionTitle ?? this.librarySectionTitle,
2026-02-08 16:03:23 +01:00
ratingImage: ratingImage ?? this.ratingImage,
audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage,
2026-02-22 17:56:13 +01:00
tagline: tagline ?? this.tagline,
originalTitle: originalTitle ?? this.originalTitle,
2026-02-14 23:09:48 -05:00
subtype: subtype ?? this.subtype,
extraType: extraType ?? this.extraType,
primaryExtraKey: primaryExtraKey ?? this.primaryExtraKey,
2025-11-21 23:59:39 +01:00
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
2025-12-11 19:15:06 +01:00
clearLogo: clearLogo ?? this.clearLogo,
2026-02-26 13:00:55 +01:00
backgroundSquare: backgroundSquare ?? this.backgroundSquare,
2025-11-04 10:28:37 +01:00
);
}
2026-02-26 13:00:55 +01:00
/// Extract an image URL by type from the Image array in raw JSON
static String? _extractImageFromJson(Map<String, dynamic> json, String imageType) {
2025-12-11 19:15:06 +01:00
if (!json.containsKey('Image')) return null;
2025-10-18 00:05:20 +02:00
final images = json['Image'] as List?;
2025-12-11 19:15:06 +01:00
if (images == null) return null;
2025-10-18 00:05:20 +02:00
for (var image in images) {
2026-02-26 13:00:55 +01:00
if (image is Map && image['type'] == imageType) {
2025-12-11 19:15:06 +01:00
return image['url'] as String?;
2025-10-18 00:05:20 +02:00
}
}
2025-12-11 19:15:06 +01:00
return null;
2025-10-18 00:05:20 +02:00
}
2026-02-26 13:00:55 +01:00
/// Create from JSON with Image array fields extracted
2025-10-18 00:05:20 +02:00
factory PlexMetadata.fromJsonWithImages(Map<String, dynamic> json) {
2026-02-26 13:00:55 +01:00
final clearLogoUrl = _extractImageFromJson(json, 'clearLogo');
2025-12-11 19:15:06 +01:00
if (clearLogoUrl != null) {
json['clearLogo'] = clearLogoUrl;
}
2026-02-26 13:00:55 +01:00
final backgroundSquareUrl = _extractImageFromJson(json, 'backgroundSquare');
if (backgroundSquareUrl != null) {
json['backgroundSquare'] = backgroundSquareUrl;
}
2025-12-11 19:15:06 +01:00
return PlexMetadata.fromJson(json);
2025-10-18 00:05:20 +02:00
}
2026-02-26 13:00:55 +01:00
/// 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;
}
2025-10-18 00:05:20 +02:00
// 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)
2025-12-15 02:29:03 +01:00
if ((itemType == 'episode' || itemType == 'season') && grandparentTitle != null) {
2025-10-18 00:05:20 +02:00
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
2025-12-15 02:29:03 +01:00
if (grandparentTitle != null || (itemType == 'season' && parentTitle != null)) {
2025-10-18 00:05:20 +02:00
return title;
}
}
return null;
}
2026-01-08 14:42:49 +01:00
/// 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)
2026-01-08 14:42:49 +01:00
/// For other types: returns thumb
String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) {
2025-10-18 00:05:20 +02:00
final itemType = type.toLowerCase();
2025-11-04 04:17:39 +01:00
if (itemType == 'episode') {
2026-01-08 14:42:49 +01:00
switch (mode) {
case EpisodePosterMode.episodeThumbnail:
return thumb; // 16:9 episode thumbnail
case EpisodePosterMode.seasonPoster:
return parentThumb ?? grandparentThumb ?? thumb;
case EpisodePosterMode.seriesPoster:
return grandparentThumb ?? thumb;
2025-11-04 04:17:39 +01:00
}
} 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!;
}
2025-10-18 00:05:20 +02:00
}
2026-01-08 14:42:49 +01:00
// For movies/shows in mixed hub context with episode thumbnail mode, use art (16:9)
2026-01-20 13:46:44 +01:00
if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && (itemType == 'movie' || itemType == 'show')) {
2026-01-08 14:42:49 +01:00
return art ?? thumb;
}
2025-10-18 00:05:20 +02:00
return thumb;
}
2026-01-08 14:42:49 +01:00
/// Returns true if this item should use 16:9 aspect ratio.
/// Episodes use 16:9 when in episodeThumbnail mode.
2026-02-15 22:59:43 +01:00
/// Clips (trailers, extras) always use 16:9.
/// Movies, shows, and seasons use 16:9 in mixed hub context with episodeThumbnail mode.
2026-01-08 14:42:49 +01:00
bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) {
final itemType = type.toLowerCase();
2026-02-15 22:59:43 +01:00
// Clips (trailers, extras) are always 16:9
if (itemType == 'clip') return true;
2026-01-08 14:42:49 +01:00
if (itemType == 'episode' && mode == EpisodePosterMode.episodeThumbnail) {
return true;
}
// Movies, shows, and seasons use 16:9 in mixed hubs with episode thumbnail mode
2026-01-20 13:46:44 +01:00
if (mixedHubContext &&
mode == EpisodePosterMode.episodeThumbnail &&
(itemType == 'movie' || itemType == 'show' || itemType == 'season')) {
2026-01-08 14:42:49 +01:00
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!;
}
2025-10-18 00:05:20 +02:00
// 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<String, dynamic> json) =>
_$PlexMetadataFromJson(kBlurArtwork ? _obfuscateJson(json) : json);
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;
}
2025-10-18 00:05:20 +02:00
Map<String, dynamic> toJson() => _$PlexMetadataToJson(this);
}