Files
plezy/lib/media/media_item.dart
T
2026-05-01 01:20:36 +02:00

1488 lines
52 KiB
Dart

import '../services/settings_service.dart' show EpisodePosterMode;
import '../utils/global_key_utils.dart';
import '../utils/json_utils.dart';
import 'media_backend.dart';
import 'media_kind.dart';
import 'media_part.dart';
import 'media_role.dart';
import 'media_version.dart';
/// Backend-neutral media item — the central domain type the app's UI,
/// providers, and persistence layer operate on. Each backend's adapter is
/// responsible for mapping its native representation (Plex `Metadata`,
/// Jellyfin `BaseItemDto`) into this shape.
///
/// Sealed root with two concrete subclasses: [PlexMediaItem] (carries
/// Plex-only fields like `trailerKey`, `playQueueItemId`, `audienceRating`)
/// and [JellyfinMediaItem] (only the backend-neutral fields). Read sites
/// that need a Plex-only field type-narrow with
/// `case PlexMediaItem(:final trailerKey?)` or
/// `if (item is PlexMediaItem) item.trailerKey`.
sealed class MediaItem {
/// Backend-opaque identifier (Plex `ratingKey`, Jellyfin `Id`).
final String id;
final MediaBackend backend;
final MediaKind kind;
/// Stable cross-backend identifier (Plex `guid`, Jellyfin `Id` URI). Used
/// for matching across servers and for Trakt-style external lookups.
final String? guid;
// ── Display metadata ─────────────────────────────────────────────
final String? title;
final String? titleSort;
final String? summary;
final String? tagline;
final String? originalTitle;
final String? studio;
final int? year;
/// Original release date (`YYYY-MM-DD`).
final String? originallyAvailableAt;
final String? contentRating;
// ── Hierarchy (episodes/seasons) ─────────────────────────────────
final String? parentId;
final String? parentTitle;
final String? parentThumbPath;
final int? parentIndex;
final int? index;
final String? grandparentId;
final String? grandparentTitle;
final String? grandparentThumbPath;
final String? grandparentArtPath;
// ── Artwork ──────────────────────────────────────────────────────
final String? thumbPath;
final String? artPath;
final String? clearLogoPath;
final String? backgroundSquarePath;
// ── Time / watch state ──────────────────────────────────────────
final int? durationMs;
/// Resume position in ms.
final int? viewOffsetMs;
final int? viewCount;
final int? lastViewedAt;
/// Total leaf items (episodes in a show/season, items in a collection).
final int? leafCount;
/// Watched leaf items.
final int? viewedLeafCount;
/// Direct children count (e.g. seasons in a show).
final int? childCount;
final int? addedAt;
final int? updatedAt;
// ── Rating ───────────────────────────────────────────────────────
final double? rating;
final double? userRating;
// ── Tags / people ────────────────────────────────────────────────
final List<String>? genres;
final List<String>? directors;
final List<String>? writers;
final List<String>? producers;
final List<String>? countries;
final List<String>? collections;
final List<String>? labels;
final List<String>? styles;
final List<String>? moods;
final List<MediaRole>? roles;
// ── Media files ──────────────────────────────────────────────────
final List<MediaVersion>? mediaVersions;
// ── Library reference ────────────────────────────────────────────
/// Backend-opaque library/section id this item belongs to.
final String? libraryId;
final String? libraryTitle;
// ── Per-item playback prefs ──────────────────────────────────────
/// Preferred audio language for this item — used by track-selection
/// fallback (Priority 3) on both backends. Plex persists changes via
/// [PlexClient.setMetadataPreferences]; Jellyfin populates it from the
/// per-user `PreferredMetadataLanguage` field but has no per-item write
/// endpoint, so the value is read-only there.
final String? audioLanguage;
// ── Multi-server ─────────────────────────────────────────────────
final String? serverId;
final String? serverName;
// ── Escape hatch ─────────────────────────────────────────────────
/// Untyped fall-through for backend-specific fields not yet mapped onto a
/// typed accessor. Use sparingly; promote to typed fields when stable.
final Map<String, Object?>? raw;
const MediaItem._({
required this.id,
required this.backend,
required this.kind,
this.guid,
this.title,
this.titleSort,
this.summary,
this.tagline,
this.originalTitle,
this.studio,
this.year,
this.originallyAvailableAt,
this.contentRating,
this.parentId,
this.parentTitle,
this.parentThumbPath,
this.parentIndex,
this.index,
this.grandparentId,
this.grandparentTitle,
this.grandparentThumbPath,
this.grandparentArtPath,
this.thumbPath,
this.artPath,
this.clearLogoPath,
this.backgroundSquarePath,
this.durationMs,
this.viewOffsetMs,
this.viewCount,
this.lastViewedAt,
this.leafCount,
this.viewedLeafCount,
this.childCount,
this.addedAt,
this.updatedAt,
this.rating,
this.userRating,
this.genres,
this.directors,
this.writers,
this.producers,
this.countries,
this.collections,
this.labels,
this.styles,
this.moods,
this.roles,
this.mediaVersions,
this.libraryId,
this.libraryTitle,
this.audioLanguage,
this.serverId,
this.serverName,
this.raw,
});
/// Backend-dispatching factory: constructs the right concrete subclass
/// for the given [backend]. Non-const because the dispatch happens at
/// runtime; the (rare) const construct sites have been adjusted.
factory MediaItem({
required String id,
required MediaBackend backend,
required MediaKind kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
/// Plex-only — silently ignored when [backend] is Jellyfin (Jellyfin has
/// no per-item subtitle preference write endpoint). Forwarded to
/// [PlexMediaItem] only.
String? subtitleLanguage,
int? subtitleMode,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
}) {
return switch (backend) {
MediaBackend.plex => PlexMediaItem(
id: id,
kind: kind,
guid: guid,
title: title,
titleSort: titleSort,
summary: summary,
tagline: tagline,
originalTitle: originalTitle,
studio: studio,
year: year,
originallyAvailableAt: originallyAvailableAt,
contentRating: contentRating,
parentId: parentId,
parentTitle: parentTitle,
parentThumbPath: parentThumbPath,
parentIndex: parentIndex,
index: index,
grandparentId: grandparentId,
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
thumbPath: thumbPath,
artPath: artPath,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
viewCount: viewCount,
lastViewedAt: lastViewedAt,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
childCount: childCount,
addedAt: addedAt,
updatedAt: updatedAt,
rating: rating,
userRating: userRating,
genres: genres,
directors: directors,
writers: writers,
producers: producers,
countries: countries,
collections: collections,
labels: labels,
styles: styles,
moods: moods,
roles: roles,
mediaVersions: mediaVersions,
libraryId: libraryId,
libraryTitle: libraryTitle,
audioLanguage: audioLanguage,
subtitleLanguage: subtitleLanguage,
subtitleMode: subtitleMode,
serverId: serverId,
serverName: serverName,
raw: raw,
),
MediaBackend.jellyfin => JellyfinMediaItem(
id: id,
kind: kind,
guid: guid,
title: title,
titleSort: titleSort,
summary: summary,
tagline: tagline,
originalTitle: originalTitle,
studio: studio,
year: year,
originallyAvailableAt: originallyAvailableAt,
contentRating: contentRating,
parentId: parentId,
parentTitle: parentTitle,
parentThumbPath: parentThumbPath,
parentIndex: parentIndex,
index: index,
grandparentId: grandparentId,
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
thumbPath: thumbPath,
artPath: artPath,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
viewCount: viewCount,
lastViewedAt: lastViewedAt,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
childCount: childCount,
addedAt: addedAt,
updatedAt: updatedAt,
rating: rating,
userRating: userRating,
genres: genres,
directors: directors,
writers: writers,
producers: producers,
countries: countries,
collections: collections,
labels: labels,
styles: styles,
moods: moods,
roles: roles,
mediaVersions: mediaVersions,
libraryId: libraryId,
libraryTitle: libraryTitle,
audioLanguage: audioLanguage,
serverId: serverId,
serverName: serverName,
raw: raw,
),
};
}
/// Global unique identifier across all servers (`serverId:id`). Falls back
/// to bare [id] if [serverId] is missing.
String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id;
/// Global unique identifier of this item's library section.
String? get libraryGlobalKey => serverId != null && libraryId != null ? buildGlobalKey(serverId!, libraryId!) : null;
/// Parent rating keys for hierarchical invalidation. For an episode:
/// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`.
List<String> get parentChain => [?parentId, ?grandparentId];
/// Whether this item has started but not finished playback.
bool get hasActiveProgress {
if (durationMs == null || viewOffsetMs == null) return false;
return viewOffsetMs! > 0 && viewOffsetMs! < durationMs!;
}
/// Whether this container (show/season) has some but not all leaves watched.
bool get isPartiallyWatched =>
viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!;
/// Whether the item is fully watched. Series/seasons consult leaf counts;
/// individual movies/episodes use [viewCount].
bool get isWatched {
if (leafCount != null && viewedLeafCount != null) {
return viewedLeafCount! >= leafCount!;
}
return viewCount != null && viewCount! > 0;
}
/// Display-friendly title that prefers the show name for episodes/seasons.
String get displayTitle {
if ((kind == MediaKind.episode || kind == MediaKind.season) && grandparentTitle != null) {
return grandparentTitle!;
}
if (kind == MediaKind.season && parentTitle != null) {
return parentTitle!;
}
return title ?? '';
}
/// Subtitle line shown below [displayTitle] for episodes/seasons.
String? get displaySubtitle {
if (kind == MediaKind.episode || kind == MediaKind.season) {
if (grandparentTitle != null || (kind == MediaKind.season && parentTitle != null)) {
return title;
}
}
return null;
}
/// Plex-only edition label (e.g. "Director's Cut"). Returns null on
/// backends that don't model editions; lets callers avoid type-narrowing
/// to [PlexMediaItem] just to read this field.
String? get editionTitle => null;
/// Returns the appropriate poster 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 in mixed hub context with episode-thumbnail mode:
/// returns art (16:9 background).
/// For other types: returns thumb.
String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) {
if (kind == MediaKind.episode) {
switch (mode) {
case EpisodePosterMode.episodeThumbnail:
return thumbPath;
case EpisodePosterMode.seasonPoster:
return parentThumbPath ?? grandparentThumbPath ?? thumbPath;
case EpisodePosterMode.seriesPoster:
return grandparentThumbPath ?? thumbPath;
}
} else if (kind == MediaKind.season) {
if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail) {
return artPath ?? thumbPath;
}
if (grandparentThumbPath != null) {
return grandparentThumbPath;
}
}
if (mixedHubContext &&
mode == EpisodePosterMode.episodeThumbnail &&
(kind == MediaKind.movie || kind == MediaKind.show)) {
return artPath ?? thumbPath;
}
return thumbPath;
}
/// True when the item should render in 16:9.
/// - Clips are always 16:9.
/// - Episodes are 16:9 in `episodeThumbnail` mode.
/// - Movies/shows/seasons are 16:9 in mixed-hub `episodeThumbnail` context.
bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) {
if (kind == MediaKind.clip) return true;
if (kind == MediaKind.episode && mode == EpisodePosterMode.episodeThumbnail) {
return true;
}
if (mixedHubContext &&
mode == EpisodePosterMode.episodeThumbnail &&
(kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season)) {
return true;
}
return false;
}
/// 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 && backgroundSquarePath != null) {
return backgroundSquarePath;
}
return artPath;
}
MediaItem copyWith({
String? id,
MediaBackend? backend,
MediaKind? kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
/// Plex-only — forwarded only when this item is a [PlexMediaItem].
String? subtitleLanguage,
int? subtitleMode,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
}) {
return MediaItem(
id: id ?? this.id,
backend: backend ?? this.backend,
kind: kind ?? this.kind,
guid: guid ?? this.guid,
title: title ?? this.title,
titleSort: titleSort ?? this.titleSort,
summary: summary ?? this.summary,
tagline: tagline ?? this.tagline,
originalTitle: originalTitle ?? this.originalTitle,
studio: studio ?? this.studio,
year: year ?? this.year,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
contentRating: contentRating ?? this.contentRating,
parentId: parentId ?? this.parentId,
parentTitle: parentTitle ?? this.parentTitle,
parentThumbPath: parentThumbPath ?? this.parentThumbPath,
parentIndex: parentIndex ?? this.parentIndex,
index: index ?? this.index,
grandparentId: grandparentId ?? this.grandparentId,
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath,
grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath,
thumbPath: thumbPath ?? this.thumbPath,
artPath: artPath ?? this.artPath,
clearLogoPath: clearLogoPath ?? this.clearLogoPath,
backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath,
durationMs: durationMs ?? this.durationMs,
viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs,
viewCount: viewCount ?? this.viewCount,
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
leafCount: leafCount ?? this.leafCount,
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
childCount: childCount ?? this.childCount,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
rating: rating ?? this.rating,
userRating: userRating ?? this.userRating,
genres: genres ?? this.genres,
directors: directors ?? this.directors,
writers: writers ?? this.writers,
producers: producers ?? this.producers,
countries: countries ?? this.countries,
collections: collections ?? this.collections,
labels: labels ?? this.labels,
styles: styles ?? this.styles,
moods: moods ?? this.moods,
roles: roles ?? this.roles,
mediaVersions: mediaVersions ?? this.mediaVersions,
libraryId: libraryId ?? this.libraryId,
libraryTitle: libraryTitle ?? this.libraryTitle,
audioLanguage: audioLanguage ?? this.audioLanguage,
// [subtitleLanguage] / [subtitleMode] are Plex-only fields. Base
// [MediaItem] doesn't carry them; [PlexMediaItem.copyWith] overrides
// this method and forwards its own copies. For Jellyfin items the
// params are silently dropped.
subtitleLanguage: subtitleLanguage,
subtitleMode: subtitleMode,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
raw: raw ?? this.raw,
);
}
/// Serialize to a backend-neutral JSON map. Used by the offline cache so
/// downloads retain their metadata without round-tripping through a
/// backend-specific shape.
///
/// Subclasses extend this with their own backend-specific keys
/// ([PlexMediaItem.toJson] adds the Plex-only fields).
Map<String, dynamic> toJson() {
return {
'id': id,
'backend': backend.id,
'kind': kind.id,
if (guid != null) 'guid': guid,
if (title != null) 'title': title,
if (titleSort != null) 'titleSort': titleSort,
if (summary != null) 'summary': summary,
if (tagline != null) 'tagline': tagline,
if (originalTitle != null) 'originalTitle': originalTitle,
if (studio != null) 'studio': studio,
if (year != null) 'year': year,
if (originallyAvailableAt != null) 'originallyAvailableAt': originallyAvailableAt,
if (contentRating != null) 'contentRating': contentRating,
if (parentId != null) 'parentId': parentId,
if (parentTitle != null) 'parentTitle': parentTitle,
if (parentThumbPath != null) 'parentThumbPath': parentThumbPath,
if (parentIndex != null) 'parentIndex': parentIndex,
if (index != null) 'index': index,
if (grandparentId != null) 'grandparentId': grandparentId,
if (grandparentTitle != null) 'grandparentTitle': grandparentTitle,
if (grandparentThumbPath != null) 'grandparentThumbPath': grandparentThumbPath,
if (grandparentArtPath != null) 'grandparentArtPath': grandparentArtPath,
if (thumbPath != null) 'thumbPath': thumbPath,
if (artPath != null) 'artPath': artPath,
if (clearLogoPath != null) 'clearLogoPath': clearLogoPath,
if (backgroundSquarePath != null) 'backgroundSquarePath': backgroundSquarePath,
if (durationMs != null) 'durationMs': durationMs,
if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs,
if (viewCount != null) 'viewCount': viewCount,
if (lastViewedAt != null) 'lastViewedAt': lastViewedAt,
if (leafCount != null) 'leafCount': leafCount,
if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount,
if (childCount != null) 'childCount': childCount,
if (addedAt != null) 'addedAt': addedAt,
if (updatedAt != null) 'updatedAt': updatedAt,
if (rating != null) 'rating': rating,
if (userRating != null) 'userRating': userRating,
if (genres != null) 'genres': genres,
if (directors != null) 'directors': directors,
if (writers != null) 'writers': writers,
if (producers != null) 'producers': producers,
if (countries != null) 'countries': countries,
if (collections != null) 'collections': collections,
if (labels != null) 'labels': labels,
if (styles != null) 'styles': styles,
if (moods != null) 'moods': moods,
if (roles != null) 'roles': [for (final r in roles!) _roleToJson(r)],
if (mediaVersions != null) 'mediaVersions': [for (final v in mediaVersions!) _versionToJson(v)],
if (libraryId != null) 'libraryId': libraryId,
if (libraryTitle != null) 'libraryTitle': libraryTitle,
if (audioLanguage != null) 'audioLanguage': audioLanguage,
if (serverId != null) 'serverId': serverId,
if (serverName != null) 'serverName': serverName,
if (raw != null) 'raw': raw,
};
}
/// Restore a [MediaItem] from a [toJson] payload. Dispatches to
/// [PlexMediaItem.fromJson] when the payload's `backend` tag is Plex so
/// the Plex-only fields round-trip correctly. Unknown shapes degrade to a
/// minimal item carrying just `id` so cache misses don't crash.
factory MediaItem.fromJson(Map<String, dynamic> json) {
final backend = MediaBackend.fromString(json['backend'] as String?);
if (backend == MediaBackend.plex) return PlexMediaItem.fromJson(json);
return JellyfinMediaItem.fromJson(json);
}
}
/// Shared parsing of the backend-neutral fields. Returns a typed record
/// consumed by both [JellyfinMediaItem.fromJson] and
/// [PlexMediaItem.fromJson] (which layers the Plex-only fields on top).
typedef _BaseFields = ({
String id,
MediaKind kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
});
_BaseFields _parseBaseFields(Map<String, dynamic> json) {
final rolesRaw = json['roles'];
final versionsRaw = json['mediaVersions'];
return (
id: (json['id'] ?? '').toString(),
kind: MediaKind.fromString(json['kind'] as String?),
guid: json['guid'] as String?,
title: json['title'] as String?,
titleSort: json['titleSort'] as String?,
summary: json['summary'] as String?,
tagline: json['tagline'] as String?,
originalTitle: json['originalTitle'] as String?,
studio: json['studio'] as String?,
year: flexibleInt(json['year']),
originallyAvailableAt: json['originallyAvailableAt'] as String?,
contentRating: json['contentRating'] as String?,
parentId: json['parentId'] as String?,
parentTitle: json['parentTitle'] as String?,
parentThumbPath: json['parentThumbPath'] as String?,
parentIndex: flexibleInt(json['parentIndex']),
index: flexibleInt(json['index']),
grandparentId: json['grandparentId'] as String?,
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumbPath: json['grandparentThumbPath'] as String?,
grandparentArtPath: json['grandparentArtPath'] as String?,
thumbPath: json['thumbPath'] as String?,
artPath: json['artPath'] as String?,
clearLogoPath: json['clearLogoPath'] as String?,
backgroundSquarePath: json['backgroundSquarePath'] as String?,
durationMs: flexibleInt(json['durationMs']),
viewOffsetMs: flexibleInt(json['viewOffsetMs']),
viewCount: flexibleInt(json['viewCount']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
leafCount: flexibleInt(json['leafCount']),
viewedLeafCount: flexibleInt(json['viewedLeafCount']),
childCount: flexibleInt(json['childCount']),
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
rating: flexibleDouble(json['rating']),
userRating: flexibleDouble(json['userRating']),
genres: _stringList(json['genres']),
directors: _stringList(json['directors']),
writers: _stringList(json['writers']),
producers: _stringList(json['producers']),
countries: _stringList(json['countries']),
collections: _stringList(json['collections']),
labels: _stringList(json['labels']),
styles: _stringList(json['styles']),
moods: _stringList(json['moods']),
roles: rolesRaw is List
? [
for (final r in rolesRaw)
if (r is Map<String, dynamic>) _roleFromJson(r),
]
: null,
mediaVersions: versionsRaw is List
? [
for (final v in versionsRaw)
if (v is Map<String, dynamic>) _versionFromJson(v),
]
: null,
libraryId: json['libraryId'] as String?,
libraryTitle: json['libraryTitle'] as String?,
audioLanguage: json['audioLanguage'] as String?,
serverId: json['serverId'] as String?,
serverName: json['serverName'] as String?,
raw: json['raw'] is Map ? Map<String, Object?>.from(json['raw'] as Map) : null,
);
}
/// Backend-tagged concrete subclass for items sourced from a Plex server.
/// Carries the Plex-only fields that have no Jellyfin equivalent
/// (trailerKey, playlistItemId, playQueueItemId, subtype, extraType,
/// ratingImage, audienceRating, audienceRatingImage, editionTitle).
/// Read sites that need these fields type-narrow with
/// `case PlexMediaItem(:final trailerKey?)` or
/// `if (item is PlexMediaItem) item.trailerKey`.
final class PlexMediaItem extends MediaItem {
/// Plex `editionTitle` — secondary title that distinguishes editions of
/// the same movie ("Director's Cut", "Theatrical"). Jellyfin has no
/// equivalent metadata field today.
@override
final String? editionTitle;
/// Plex `audienceRating` (e.g. Rotten Tomatoes audience score). Jellyfin's
/// `CommunityRating` lives on [rating]; there's no separate audience field.
final double? audienceRating;
/// Plex `ratingImage` URI ("rottentomatoes://image.rating.ripe"). Used by
/// the rating chip to pick an icon. Jellyfin doesn't expose
/// rating-source attribution.
final String? ratingImage;
/// Plex `audienceRatingImage` URI — companion to [ratingImage] for the
/// audience score icon.
final String? audienceRatingImage;
/// Plex per-item subtitle language preference — persisted server-side via
/// [PlexClient.setMetadataPreferences]. Jellyfin has no equivalent
/// per-item write endpoint, so the field lives here rather than on the
/// neutral [MediaItem] base.
final String? subtitleLanguage;
/// Plex per-item subtitle mode (`0` = manual, `1` = always on, `2` = match
/// audio). Jellyfin doesn't expose a comparable knob.
final int? subtitleMode;
/// Plex `primaryExtraKey` — points at the main trailer extra. Jellyfin
/// stores trailers separately via `RemoteTrailers`; not yet wired.
final String? trailerKey;
/// Plex playlist item id — only set when the item came out of a
/// server-side playlist. Jellyfin has no per-playlist-item id.
final int? playlistItemId;
/// Plex play-queue item id — set when the item is part of a server-side
/// `PlayQueue`. Jellyfin uses client-side queues; [PlaybackStateProvider]
/// tracks synthetic IDs in a parallel map for those.
final int? playQueueItemId;
/// Plex clip subtype: `trailer`, `behindTheScenes`, `deleted`, etc.
final String? subtype;
/// Plex numeric extra type identifier.
final int? extraType;
const PlexMediaItem({
required super.id,
required super.kind,
super.guid,
super.title,
super.titleSort,
super.summary,
super.tagline,
super.originalTitle,
this.editionTitle,
super.studio,
super.year,
super.originallyAvailableAt,
super.contentRating,
super.parentId,
super.parentTitle,
super.parentThumbPath,
super.parentIndex,
super.index,
super.grandparentId,
super.grandparentTitle,
super.grandparentThumbPath,
super.grandparentArtPath,
super.thumbPath,
super.artPath,
super.clearLogoPath,
super.backgroundSquarePath,
super.durationMs,
super.viewOffsetMs,
super.viewCount,
super.lastViewedAt,
super.leafCount,
super.viewedLeafCount,
super.childCount,
super.addedAt,
super.updatedAt,
super.rating,
this.audienceRating,
super.userRating,
this.ratingImage,
this.audienceRatingImage,
super.genres,
super.directors,
super.writers,
super.producers,
super.countries,
super.collections,
super.labels,
super.styles,
super.moods,
super.roles,
super.mediaVersions,
super.libraryId,
super.libraryTitle,
super.audioLanguage,
this.subtitleLanguage,
this.subtitleMode,
this.trailerKey,
this.playlistItemId,
this.playQueueItemId,
this.subtype,
this.extraType,
super.serverId,
super.serverName,
super.raw,
}) : super._(backend: MediaBackend.plex);
@override
PlexMediaItem copyWith({
String? id,
MediaBackend? backend,
MediaKind? kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? editionTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? audienceRating,
double? userRating,
String? ratingImage,
String? audienceRatingImage,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? subtitleLanguage,
int? subtitleMode,
String? trailerKey,
int? playlistItemId,
int? playQueueItemId,
String? subtype,
int? extraType,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
}) {
return PlexMediaItem(
id: id ?? this.id,
kind: kind ?? this.kind,
guid: guid ?? this.guid,
title: title ?? this.title,
titleSort: titleSort ?? this.titleSort,
summary: summary ?? this.summary,
tagline: tagline ?? this.tagline,
originalTitle: originalTitle ?? this.originalTitle,
editionTitle: editionTitle ?? this.editionTitle,
studio: studio ?? this.studio,
year: year ?? this.year,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
contentRating: contentRating ?? this.contentRating,
parentId: parentId ?? this.parentId,
parentTitle: parentTitle ?? this.parentTitle,
parentThumbPath: parentThumbPath ?? this.parentThumbPath,
parentIndex: parentIndex ?? this.parentIndex,
index: index ?? this.index,
grandparentId: grandparentId ?? this.grandparentId,
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath,
grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath,
thumbPath: thumbPath ?? this.thumbPath,
artPath: artPath ?? this.artPath,
clearLogoPath: clearLogoPath ?? this.clearLogoPath,
backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath,
durationMs: durationMs ?? this.durationMs,
viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs,
viewCount: viewCount ?? this.viewCount,
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
leafCount: leafCount ?? this.leafCount,
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
childCount: childCount ?? this.childCount,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
rating: rating ?? this.rating,
audienceRating: audienceRating ?? this.audienceRating,
userRating: userRating ?? this.userRating,
ratingImage: ratingImage ?? this.ratingImage,
audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage,
genres: genres ?? this.genres,
directors: directors ?? this.directors,
writers: writers ?? this.writers,
producers: producers ?? this.producers,
countries: countries ?? this.countries,
collections: collections ?? this.collections,
labels: labels ?? this.labels,
styles: styles ?? this.styles,
moods: moods ?? this.moods,
roles: roles ?? this.roles,
mediaVersions: mediaVersions ?? this.mediaVersions,
libraryId: libraryId ?? this.libraryId,
libraryTitle: libraryTitle ?? this.libraryTitle,
audioLanguage: audioLanguage ?? this.audioLanguage,
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
subtitleMode: subtitleMode ?? this.subtitleMode,
trailerKey: trailerKey ?? this.trailerKey,
playlistItemId: playlistItemId ?? this.playlistItemId,
playQueueItemId: playQueueItemId ?? this.playQueueItemId,
subtype: subtype ?? this.subtype,
extraType: extraType ?? this.extraType,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
raw: raw ?? this.raw,
);
}
@override
Map<String, dynamic> toJson() {
return {
...super.toJson(),
if (editionTitle != null) 'editionTitle': editionTitle,
if (audienceRating != null) 'audienceRating': audienceRating,
if (ratingImage != null) 'ratingImage': ratingImage,
if (audienceRatingImage != null) 'audienceRatingImage': audienceRatingImage,
if (subtitleLanguage != null) 'subtitleLanguage': subtitleLanguage,
if (subtitleMode != null) 'subtitleMode': subtitleMode,
if (trailerKey != null) 'trailerKey': trailerKey,
if (playlistItemId != null) 'playlistItemId': playlistItemId,
if (playQueueItemId != null) 'playQueueItemId': playQueueItemId,
if (subtype != null) 'subtype': subtype,
if (extraType != null) 'extraType': extraType,
};
}
/// Restore a [PlexMediaItem] from a [toJson] payload. Reads the Plex-only
/// keys on top of the backend-neutral fields parsed by [_parseBaseFields].
factory PlexMediaItem.fromJson(Map<String, dynamic> json) {
final base = _parseBaseFields(json);
return PlexMediaItem(
id: base.id,
kind: base.kind,
guid: base.guid,
title: base.title,
titleSort: base.titleSort,
summary: base.summary,
tagline: base.tagline,
originalTitle: base.originalTitle,
editionTitle: json['editionTitle'] as String?,
studio: base.studio,
year: base.year,
originallyAvailableAt: base.originallyAvailableAt,
contentRating: base.contentRating,
parentId: base.parentId,
parentTitle: base.parentTitle,
parentThumbPath: base.parentThumbPath,
parentIndex: base.parentIndex,
index: base.index,
grandparentId: base.grandparentId,
grandparentTitle: base.grandparentTitle,
grandparentThumbPath: base.grandparentThumbPath,
grandparentArtPath: base.grandparentArtPath,
thumbPath: base.thumbPath,
artPath: base.artPath,
clearLogoPath: base.clearLogoPath,
backgroundSquarePath: base.backgroundSquarePath,
durationMs: base.durationMs,
viewOffsetMs: base.viewOffsetMs,
viewCount: base.viewCount,
lastViewedAt: base.lastViewedAt,
leafCount: base.leafCount,
viewedLeafCount: base.viewedLeafCount,
childCount: base.childCount,
addedAt: base.addedAt,
updatedAt: base.updatedAt,
rating: base.rating,
audienceRating: flexibleDouble(json['audienceRating']),
userRating: base.userRating,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
genres: base.genres,
directors: base.directors,
writers: base.writers,
producers: base.producers,
countries: base.countries,
collections: base.collections,
labels: base.labels,
styles: base.styles,
moods: base.moods,
roles: base.roles,
mediaVersions: base.mediaVersions,
libraryId: base.libraryId,
libraryTitle: base.libraryTitle,
audioLanguage: base.audioLanguage,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: flexibleInt(json['subtitleMode']),
trailerKey: json['trailerKey'] as String?,
playlistItemId: flexibleInt(json['playlistItemId']),
playQueueItemId: flexibleInt(json['playQueueItemId']),
subtype: json['subtype'] as String?,
extraType: flexibleInt(json['extraType']),
serverId: base.serverId,
serverName: base.serverName,
raw: base.raw,
);
}
}
/// Backend-tagged concrete subclass for items sourced from a Jellyfin
/// server. Carries only the backend-neutral fields — Plex-only fields
/// (trailerKey, audienceRating, etc.) live on [PlexMediaItem] instead.
final class JellyfinMediaItem extends MediaItem {
/// Jellyfin per-playlist item id — only set when the item came out of
/// `/Playlists/{id}/Items`. Used as the `entryIds` / move-target id for
/// the playlist write endpoints. Null outside playlist contexts.
final String? playlistItemId;
const JellyfinMediaItem({
required super.id,
required super.kind,
super.guid,
super.title,
super.titleSort,
super.summary,
super.tagline,
super.originalTitle,
super.studio,
super.year,
super.originallyAvailableAt,
super.contentRating,
super.parentId,
super.parentTitle,
super.parentThumbPath,
super.parentIndex,
super.index,
super.grandparentId,
super.grandparentTitle,
super.grandparentThumbPath,
super.grandparentArtPath,
super.thumbPath,
super.artPath,
super.clearLogoPath,
super.backgroundSquarePath,
super.durationMs,
super.viewOffsetMs,
super.viewCount,
super.lastViewedAt,
super.leafCount,
super.viewedLeafCount,
super.childCount,
super.addedAt,
super.updatedAt,
super.rating,
super.userRating,
super.genres,
super.directors,
super.writers,
super.producers,
super.countries,
super.collections,
super.labels,
super.styles,
super.moods,
super.roles,
super.mediaVersions,
super.libraryId,
super.libraryTitle,
super.audioLanguage,
this.playlistItemId,
super.serverId,
super.serverName,
super.raw,
}) : super._(backend: MediaBackend.jellyfin);
/// Override the base [MediaItem.copyWith] so [playlistItemId] survives
/// round-trips through the absolutizer (which calls copyWith to rewrite
/// image paths). Without this, every Jellyfin playlist item came out with
/// `playlistItemId == null` after mapping, making the move/remove endpoints
/// silently no-op.
@override
JellyfinMediaItem copyWith({
String? id,
MediaBackend? backend,
MediaKind? kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? subtitleLanguage,
int? subtitleMode,
String? playlistItemId,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
}) {
return JellyfinMediaItem(
id: id ?? this.id,
kind: kind ?? this.kind,
guid: guid ?? this.guid,
title: title ?? this.title,
titleSort: titleSort ?? this.titleSort,
summary: summary ?? this.summary,
tagline: tagline ?? this.tagline,
originalTitle: originalTitle ?? this.originalTitle,
studio: studio ?? this.studio,
year: year ?? this.year,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
contentRating: contentRating ?? this.contentRating,
parentId: parentId ?? this.parentId,
parentTitle: parentTitle ?? this.parentTitle,
parentThumbPath: parentThumbPath ?? this.parentThumbPath,
parentIndex: parentIndex ?? this.parentIndex,
index: index ?? this.index,
grandparentId: grandparentId ?? this.grandparentId,
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath,
grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath,
thumbPath: thumbPath ?? this.thumbPath,
artPath: artPath ?? this.artPath,
clearLogoPath: clearLogoPath ?? this.clearLogoPath,
backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath,
durationMs: durationMs ?? this.durationMs,
viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs,
viewCount: viewCount ?? this.viewCount,
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
leafCount: leafCount ?? this.leafCount,
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
childCount: childCount ?? this.childCount,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
rating: rating ?? this.rating,
userRating: userRating ?? this.userRating,
genres: genres ?? this.genres,
directors: directors ?? this.directors,
writers: writers ?? this.writers,
producers: producers ?? this.producers,
countries: countries ?? this.countries,
collections: collections ?? this.collections,
labels: labels ?? this.labels,
styles: styles ?? this.styles,
moods: moods ?? this.moods,
roles: roles ?? this.roles,
mediaVersions: mediaVersions ?? this.mediaVersions,
libraryId: libraryId ?? this.libraryId,
libraryTitle: libraryTitle ?? this.libraryTitle,
audioLanguage: audioLanguage ?? this.audioLanguage,
playlistItemId: playlistItemId ?? this.playlistItemId,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
raw: raw ?? this.raw,
);
}
@override
Map<String, dynamic> toJson() {
return {...super.toJson(), if (playlistItemId != null) 'playlistItemId': playlistItemId};
}
/// Restore a [JellyfinMediaItem] from a [toJson] payload. Used as the
/// non-Plex fallback by [MediaItem.fromJson].
factory JellyfinMediaItem.fromJson(Map<String, dynamic> json) {
final base = _parseBaseFields(json);
return JellyfinMediaItem(
id: base.id,
kind: base.kind,
guid: base.guid,
title: base.title,
titleSort: base.titleSort,
summary: base.summary,
tagline: base.tagline,
originalTitle: base.originalTitle,
studio: base.studio,
year: base.year,
originallyAvailableAt: base.originallyAvailableAt,
contentRating: base.contentRating,
parentId: base.parentId,
parentTitle: base.parentTitle,
parentThumbPath: base.parentThumbPath,
parentIndex: base.parentIndex,
index: base.index,
grandparentId: base.grandparentId,
grandparentTitle: base.grandparentTitle,
grandparentThumbPath: base.grandparentThumbPath,
grandparentArtPath: base.grandparentArtPath,
thumbPath: base.thumbPath,
artPath: base.artPath,
clearLogoPath: base.clearLogoPath,
backgroundSquarePath: base.backgroundSquarePath,
durationMs: base.durationMs,
viewOffsetMs: base.viewOffsetMs,
viewCount: base.viewCount,
lastViewedAt: base.lastViewedAt,
leafCount: base.leafCount,
viewedLeafCount: base.viewedLeafCount,
childCount: base.childCount,
addedAt: base.addedAt,
updatedAt: base.updatedAt,
rating: base.rating,
userRating: base.userRating,
genres: base.genres,
directors: base.directors,
writers: base.writers,
producers: base.producers,
countries: base.countries,
collections: base.collections,
labels: base.labels,
styles: base.styles,
moods: base.moods,
roles: base.roles,
mediaVersions: base.mediaVersions,
libraryId: base.libraryId,
libraryTitle: base.libraryTitle,
audioLanguage: base.audioLanguage,
playlistItemId: json['playlistItemId'] as String?,
serverId: base.serverId,
serverName: base.serverName,
raw: base.raw,
);
}
}
List<String>? _stringList(Object? raw) {
return stringListFromRaw(raw, stringify: true);
}
Map<String, dynamic> _roleToJson(MediaRole role) => {
if (role.id != null) 'id': role.id,
'tag': role.tag,
if (role.role != null) 'role': role.role,
if (role.thumbPath != null) 'thumbPath': role.thumbPath,
};
MediaRole _roleFromJson(Map<String, dynamic> json) => MediaRole(
id: json['id'] as String?,
tag: (json['tag'] ?? '').toString(),
role: json['role'] as String?,
thumbPath: json['thumbPath'] as String?,
);
Map<String, dynamic> _versionToJson(MediaVersion v) => {
'id': v.id,
if (v.width != null) 'width': v.width,
if (v.height != null) 'height': v.height,
if (v.videoResolution != null) 'videoResolution': v.videoResolution,
if (v.videoCodec != null) 'videoCodec': v.videoCodec,
if (v.bitrate != null) 'bitrate': v.bitrate,
if (v.container != null) 'container': v.container,
if (v.name != null) 'name': v.name,
'parts': [
for (final p in v.parts)
{
'id': p.id,
if (p.streamPath != null) 'streamPath': p.streamPath,
if (p.sizeBytes != null) 'sizeBytes': p.sizeBytes,
if (p.container != null) 'container': p.container,
if (p.durationMs != null) 'durationMs': p.durationMs,
if (p.accessible != null) 'accessible': p.accessible,
if (p.exists != null) 'exists': p.exists,
},
],
};
MediaVersion _versionFromJson(Map<String, dynamic> json) {
final partsRaw = json['parts'];
return MediaVersion(
id: (json['id'] ?? '').toString(),
width: flexibleInt(json['width']),
height: flexibleInt(json['height']),
videoResolution: json['videoResolution'] as String?,
videoCodec: json['videoCodec'] as String?,
bitrate: flexibleInt(json['bitrate']),
container: json['container'] as String?,
name: json['name'] as String?,
parts: partsRaw is List
? [
for (final p in partsRaw)
if (p is Map<String, dynamic>)
MediaPart(
id: (p['id'] ?? '').toString(),
streamPath: p['streamPath'] as String?,
sizeBytes: flexibleInt(p['sizeBytes']),
container: p['container'] as String?,
durationMs: flexibleInt(p['durationMs']),
accessible: p['accessible'] as bool?,
exists: p['exists'] as bool?,
),
]
: const [],
);
}