Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor took `rating` and fell back to `audienceRating` only when it was null, so a Plex movie carrying four attributed scores surfaced one, and which one was whatever the server happened to put in the scalar slot. #1755 asked for a setting to choose the source; showing all of them answers it without one. The data was already on the wire and being thrown away. `/library/metadata/ {id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels, TMDB — with no extra query parameter, but PlexMetadataDto declared no field for it, so json_serializable dropped the key. The identical parse already existed in plex_catalog_source for the Explore tab and had simply never been wired to library items. Model the scores as a list rather than widening the scalar pair. The neutral MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage and audienceRatingImage, which the list subsumes — Plex sends those images on listings too, so the same field covers both response shapes and no caller narrows to a backend type to read a score any more. CatalogRatingSource is promoted to lib/media as MediaRatingSource instead of growing a second near-identical type beside it, and plex_catalog_source's _ratingsFor becomes the shared plexRatingSources so one implementation serves both paths. There is no persistence to migrate: MediaItem.toJson has no production caller, the offline path re-parses raw Plex JSON through the same mapper, and Plex's audienceRating sort is server-supplied data, not a model read. Cards and the dashboard still show fewer scores than detail screens, and that part is a real Plex limit rather than a shortcut. Section listings send only the scalar pair; includeRatings, includeElements=Rating, includeFields=Rating, includeChildren and includeExtras were each probed against a live server and none surfaced the array, while includeGuids=1 demonstrably does add Guid[] — the probe works, the parameter does not exist. Hydrating every card would be one request per row, so listings render whatever their own response carried, which is one or two attributed scores rather than the single one they showed before. Jellyfin has no per-source array at all: the server collapses whatever its fetchers found into CommunityRating and CriticRating. CommunityRating's provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or a local NFO, last writer wins — so it stays the generic `audience` source with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a 0-100 percent and is divided by ten explicitly rather than folded by magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin reuses CommunityRating for the EXIF 0-5 star. The badges share one slot on every surface. On the phone hero the scores go in a single pill because that chip row is a height-clipped Wrap and a chip per source would push year, certification and runtime out of the visible band on short heroes; on the TV detail line and the dashboard spotlight the group occupies the one metadata slot so bullet separators do not multiply. The group announces itself as a single semantics node naming each source, because a bare row of four percentages tells a screen reader nothing about which score is which. rating_utils drops parseRatingImage and isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper — and the source-key resolver and label map, previously private to the Explore detail screen, become the shared pair both screens use. The label strings move from explore.ratingSource to common.ratingSource accordingly, which costs no translations because every non-English value was empty; running clean_translations also scaffolds startup.quitPlezy and startup.restartRequiredBody, which were already drifted. Verified against the live server the probes came from: a detail response now yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the production mapper and badge resolver, and the listing response for the same title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures. Coverage adds mapper ordering, dedupe against the array's repeat of the scalar, out-of-range rejection, the Jellyfin scale and photo guard, the CatalogItem conversion that feeds Explore's dashboard hubs, and the three render surfaces including the semantics announcement. close #1755
773 lines
29 KiB
Dart
773 lines
29 KiB
Dart
// ignore_for_file: invalid_annotation_target
|
||
|
||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||
import 'ids.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_role.dart';
|
||
import 'media_version.dart';
|
||
import 'media_rating.dart';
|
||
|
||
part 'media_item.freezed.dart';
|
||
part 'media_item.g.dart';
|
||
|
||
/// Container aspect ratio below which a hero prefers square background art.
|
||
/// A 16:9 backdrop only cover-fits a taller box by discarding most of the
|
||
/// frame, so portrait phone/tablet heroes read better with the square image.
|
||
const double _squareHeroAspectRatio = 1.39;
|
||
|
||
/// Backend-neutral media item shape used by UI, providers, persistence, and
|
||
/// playback. Concrete variants retain backend-only fields without forcing the
|
||
/// rest of the app to traffic in Plex/Jellyfin DTOs.
|
||
@Freezed(unionKey: 'backend', unionValueCase: FreezedUnionCase.none, equal: false, makeCollectionsUnmodifiable: false)
|
||
sealed class MediaItem with _$MediaItem {
|
||
const MediaItem._();
|
||
|
||
/// Backend-dispatching compatibility factory used by existing call sites.
|
||
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,
|
||
List<String>? grandparentBackdropPaths,
|
||
String? thumbPath,
|
||
String? artPath,
|
||
List<String>? backdropPaths,
|
||
String? clearLogoPath,
|
||
String? backgroundSquarePath,
|
||
int? durationMs,
|
||
int? viewOffsetMs,
|
||
int? viewCount,
|
||
int? lastViewedAt,
|
||
int? leafCount,
|
||
int? viewedLeafCount,
|
||
int? childCount,
|
||
int? addedAt,
|
||
int? updatedAt,
|
||
double? rating,
|
||
List<MediaRatingSource>? ratings,
|
||
double? userRating,
|
||
bool? isFavorite,
|
||
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? serverId,
|
||
String? serverName,
|
||
String? backendFolderKey,
|
||
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,
|
||
grandparentBackdropPaths: grandparentBackdropPaths,
|
||
thumbPath: thumbPath,
|
||
artPath: artPath,
|
||
backdropPaths: backdropPaths,
|
||
clearLogoPath: clearLogoPath,
|
||
backgroundSquarePath: backgroundSquarePath,
|
||
durationMs: durationMs,
|
||
viewOffsetMs: viewOffsetMs,
|
||
viewCount: viewCount,
|
||
lastViewedAt: lastViewedAt,
|
||
leafCount: leafCount,
|
||
viewedLeafCount: viewedLeafCount,
|
||
childCount: childCount,
|
||
addedAt: addedAt,
|
||
updatedAt: updatedAt,
|
||
rating: rating,
|
||
ratings: ratings,
|
||
userRating: userRating,
|
||
isFavorite: isFavorite,
|
||
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,
|
||
backendFolderKey: backendFolderKey,
|
||
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,
|
||
grandparentBackdropPaths: grandparentBackdropPaths,
|
||
thumbPath: thumbPath,
|
||
artPath: artPath,
|
||
backdropPaths: backdropPaths,
|
||
clearLogoPath: clearLogoPath,
|
||
backgroundSquarePath: backgroundSquarePath,
|
||
durationMs: durationMs,
|
||
viewOffsetMs: viewOffsetMs,
|
||
viewCount: viewCount,
|
||
lastViewedAt: lastViewedAt,
|
||
leafCount: leafCount,
|
||
viewedLeafCount: viewedLeafCount,
|
||
childCount: childCount,
|
||
addedAt: addedAt,
|
||
updatedAt: updatedAt,
|
||
rating: rating,
|
||
ratings: ratings,
|
||
userRating: userRating,
|
||
isFavorite: isFavorite,
|
||
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,
|
||
backendFolderKey: backendFolderKey,
|
||
raw: raw,
|
||
),
|
||
};
|
||
}
|
||
|
||
/// Backend-tagged concrete subclass for items sourced from a Plex server.
|
||
@FreezedUnionValue('plex')
|
||
@JsonSerializable(includeIfNull: false, explicitToJson: true)
|
||
const factory MediaItem.plex({
|
||
@JsonKey(readValue: readStringField, defaultValue: '') required String id,
|
||
@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required MediaKind kind,
|
||
String? guid,
|
||
String? title,
|
||
String? titleSort,
|
||
String? summary,
|
||
String? tagline,
|
||
String? originalTitle,
|
||
|
||
/// Plex `editionTitle` distinguishes versions of the same movie.
|
||
String? editionTitle,
|
||
String? studio,
|
||
@JsonKey(fromJson: flexibleInt) int? year,
|
||
String? originallyAvailableAt,
|
||
String? contentRating,
|
||
String? parentId,
|
||
String? parentTitle,
|
||
String? parentThumbPath,
|
||
@JsonKey(fromJson: flexibleInt) int? parentIndex,
|
||
@JsonKey(fromJson: flexibleInt) int? index,
|
||
String? grandparentId,
|
||
String? grandparentTitle,
|
||
String? grandparentThumbPath,
|
||
String? grandparentArtPath,
|
||
List<String>? grandparentBackdropPaths,
|
||
String? thumbPath,
|
||
String? artPath,
|
||
List<String>? backdropPaths,
|
||
String? clearLogoPath,
|
||
String? backgroundSquarePath,
|
||
@JsonKey(fromJson: flexibleInt) int? durationMs,
|
||
@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,
|
||
@JsonKey(fromJson: flexibleInt) int? viewCount,
|
||
@JsonKey(fromJson: flexibleInt) int? lastViewedAt,
|
||
@JsonKey(fromJson: flexibleInt) int? leafCount,
|
||
@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,
|
||
@JsonKey(fromJson: flexibleInt) int? childCount,
|
||
@JsonKey(fromJson: flexibleInt) int? addedAt,
|
||
@JsonKey(fromJson: flexibleInt) int? updatedAt,
|
||
@JsonKey(fromJson: flexibleDouble) double? rating,
|
||
@JsonKey(fromJson: flexibleDouble) double? userRating,
|
||
|
||
/// Every attributed score the response carried, headline first. Plex
|
||
/// listings yield one or two (the `rating`/`audienceRating` pair with
|
||
/// their source images); `/library/metadata/{id}` adds the `Rating[]`
|
||
/// array, so IMDb and TMDB join Rotten Tomatoes on detail screens.
|
||
@JsonKey(fromJson: _mediaItemRatingsFromJson) List<MediaRatingSource>? ratings,
|
||
bool? isFavorite,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? genres,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? directors,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? writers,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? producers,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? countries,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? collections,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? labels,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? styles,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? moods,
|
||
@JsonKey(fromJson: _mediaItemRolesFromJson) List<MediaRole>? roles,
|
||
@JsonKey(fromJson: _mediaItemVersionsFromJson) List<MediaVersion>? mediaVersions,
|
||
String? libraryId,
|
||
String? libraryTitle,
|
||
String? audioLanguage,
|
||
String? subtitleLanguage,
|
||
@JsonKey(fromJson: flexibleInt) int? subtitleMode,
|
||
String? trailerKey,
|
||
@JsonKey(fromJson: flexibleInt) int? playlistItemId,
|
||
@JsonKey(fromJson: flexibleInt) int? playQueueItemId,
|
||
String? subtype,
|
||
@JsonKey(fromJson: flexibleInt) int? extraType,
|
||
String? serverId,
|
||
String? serverName,
|
||
|
||
/// Relative folder key (`/library/sections/{id}/folder?parent=…`) for
|
||
/// [MediaKind.folder] rows — what [MediaServerClient.fetchFolderChildren]
|
||
/// tunes into. Stamped by the folder fetchers, null elsewhere.
|
||
String? backendFolderKey,
|
||
@JsonKey(fromJson: _mediaItemRawFromJson) Map<String, Object?>? raw,
|
||
}) = PlexMediaItem;
|
||
|
||
/// Backend-tagged concrete subclass for items sourced from a Jellyfin server.
|
||
@FreezedUnionValue('jellyfin')
|
||
@JsonSerializable(includeIfNull: false, explicitToJson: true)
|
||
const factory MediaItem.jellyfin({
|
||
@JsonKey(readValue: readStringField, defaultValue: '') required String id,
|
||
@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required MediaKind kind,
|
||
String? guid,
|
||
String? title,
|
||
String? titleSort,
|
||
String? summary,
|
||
String? tagline,
|
||
String? originalTitle,
|
||
String? studio,
|
||
@JsonKey(fromJson: flexibleInt) int? year,
|
||
String? originallyAvailableAt,
|
||
String? contentRating,
|
||
String? parentId,
|
||
String? parentTitle,
|
||
String? parentThumbPath,
|
||
@JsonKey(fromJson: flexibleInt) int? parentIndex,
|
||
@JsonKey(fromJson: flexibleInt) int? index,
|
||
String? grandparentId,
|
||
String? grandparentTitle,
|
||
String? grandparentThumbPath,
|
||
String? grandparentArtPath,
|
||
List<String>? grandparentBackdropPaths,
|
||
String? thumbPath,
|
||
String? artPath,
|
||
List<String>? backdropPaths,
|
||
String? clearLogoPath,
|
||
String? backgroundSquarePath,
|
||
@JsonKey(fromJson: flexibleInt) int? durationMs,
|
||
@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,
|
||
@JsonKey(fromJson: flexibleInt) int? viewCount,
|
||
@JsonKey(fromJson: flexibleInt) int? lastViewedAt,
|
||
@JsonKey(fromJson: flexibleInt) int? leafCount,
|
||
@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,
|
||
@JsonKey(fromJson: flexibleInt) int? childCount,
|
||
@JsonKey(fromJson: flexibleInt) int? addedAt,
|
||
@JsonKey(fromJson: flexibleInt) int? updatedAt,
|
||
@JsonKey(fromJson: flexibleDouble) double? rating,
|
||
@JsonKey(fromJson: flexibleDouble) double? userRating,
|
||
|
||
/// `CommunityRating` and the `CriticRating` Tomatometer, in that order.
|
||
/// Jellyfin exposes no per-source array, so this is at most two entries.
|
||
@JsonKey(fromJson: _mediaItemRatingsFromJson) List<MediaRatingSource>? ratings,
|
||
bool? isFavorite,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? genres,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? directors,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? writers,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? producers,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? countries,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? collections,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? labels,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? styles,
|
||
@JsonKey(fromJson: _mediaItemStringList) List<String>? moods,
|
||
@JsonKey(fromJson: _mediaItemRolesFromJson) List<MediaRole>? roles,
|
||
@JsonKey(fromJson: _mediaItemVersionsFromJson) List<MediaVersion>? mediaVersions,
|
||
String? libraryId,
|
||
String? libraryTitle,
|
||
String? audioLanguage,
|
||
|
||
/// Jellyfin playlist entry id used by playlist write endpoints.
|
||
String? playlistItemId,
|
||
String? serverId,
|
||
String? serverName,
|
||
|
||
/// Always null on Jellyfin — folder children are fetched by [id]. Exists
|
||
/// on both variants so the union exposes one neutral getter.
|
||
String? backendFolderKey,
|
||
@JsonKey(fromJson: _mediaItemRawFromJson) Map<String, Object?>? raw,
|
||
}) = JellyfinMediaItem;
|
||
|
||
MediaBackend get backend => switch (this) {
|
||
PlexMediaItem() => MediaBackend.plex,
|
||
JellyfinMediaItem() => MediaBackend.jellyfin,
|
||
};
|
||
|
||
/// Restore a [MediaItem] from a [toJson] payload. Missing/unknown backend
|
||
/// values use [MediaBackend.fromString] so old offline cache rows remain
|
||
/// readable instead of throwing before union dispatch.
|
||
factory MediaItem.fromJson(Map<String, dynamic> json) {
|
||
return switch (MediaBackend.fromString(json['backend'] as String?)) {
|
||
MediaBackend.plex => _$PlexMediaItemFromJson(json),
|
||
MediaBackend.jellyfin => _$JellyfinMediaItemFromJson(json),
|
||
};
|
||
}
|
||
|
||
Map<String, dynamic> toJson() {
|
||
return switch (this) {
|
||
final PlexMediaItem item => {'backend': MediaBackend.plex.id, ..._$PlexMediaItemToJson(item)},
|
||
final JellyfinMediaItem item => {'backend': MediaBackend.jellyfin.id, ..._$JellyfinMediaItemToJson(item)},
|
||
};
|
||
}
|
||
|
||
/// Global unique identifier across all servers (`serverId:id`). Falls back
|
||
/// to bare [id] if [serverId] is missing.
|
||
String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id;
|
||
|
||
/// Global unique identifier of this item's library section.
|
||
String? get libraryGlobalKey =>
|
||
serverId != null && libraryId != null ? buildGlobalKey(ServerId(serverId!), libraryId!) : null;
|
||
|
||
/// Global unique identifier of this item's series, for episodes/seasons.
|
||
/// Null for movies and shows themselves — their own [globalKey] is already
|
||
/// series-level.
|
||
String? get seriesGlobalKey {
|
||
final seriesId = switch (kind) {
|
||
MediaKind.episode => grandparentId,
|
||
MediaKind.season => grandparentId ?? parentId,
|
||
_ => null,
|
||
};
|
||
if (seriesId == null) return null;
|
||
return serverId != null ? buildGlobalKey(ServerId(serverId!), seriesId) : seriesId;
|
||
}
|
||
|
||
/// Parent rating keys for hierarchical invalidation. For an episode:
|
||
/// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`.
|
||
List<String> get parentChain => [?parentId, ?grandparentId];
|
||
|
||
/// Server-side file paths across every version of this item. Plex
|
||
/// represents a multi-episode file (`S02E24-E25.mkv`) as distinct episode
|
||
/// items whose parts have *different* part ids but the same file, so the
|
||
/// file path — not the part id — is the "same underlying file" signal
|
||
/// (#1500).
|
||
Set<String> get allPartFiles => {
|
||
for (final version in mediaVersions ?? const <MediaVersion>[])
|
||
for (final part in version.parts)
|
||
if (part.file != null && part.file!.isNotEmpty) part.file!,
|
||
};
|
||
|
||
/// Whether [other] is backed by the same physical file as this item.
|
||
/// [playedPartId] — the part actually being played, when known — pins the
|
||
/// comparison to that part's file, so an episode with multiple versions
|
||
/// only matches against the file on screen; otherwise any file overlap
|
||
/// between the two items counts. Items without file metadata (Plex hides
|
||
/// paths from restricted users) or from a different server never match.
|
||
bool sharesFileWith(MediaItem other, {String? playedPartId}) {
|
||
if (other.serverId != serverId) return false;
|
||
final otherFiles = other.allPartFiles;
|
||
if (otherFiles.isEmpty) return false;
|
||
if (playedPartId != null) {
|
||
final playedFile = _filePathForPart(playedPartId);
|
||
if (playedFile != null) return otherFiles.contains(playedFile);
|
||
}
|
||
return allPartFiles.intersection(otherFiles).isNotEmpty;
|
||
}
|
||
|
||
/// The file path of this item's part with [partId], or null when unknown.
|
||
String? _filePathForPart(String partId) {
|
||
for (final version in mediaVersions ?? const <MediaVersion>[]) {
|
||
for (final part in version.parts) {
|
||
if (part.id == partId) return (part.file?.isEmpty ?? true) ? null : part.file;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Recency used to order the Continue Watching / On Deck shelf: when the item
|
||
/// was last watched, falling back to when it was added for never-watched rows.
|
||
/// Shared by the per-client merge and the cross-server sort so they agree.
|
||
int get recencySortKey => lastViewedAt ?? addedAt ?? 0;
|
||
|
||
/// 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 item still counts toward an "unwatched only" selection:
|
||
/// not fully watched, or watched-but-resumable (has active progress). The
|
||
/// shared predicate behind every `unwatchedOnly` filter (downloads, sync
|
||
/// rules, the unwatched-episode lookups in episode_collection.dart).
|
||
bool get isUnwatchedOrInProgress => !isWatched || hasActiveProgress;
|
||
|
||
/// Positive leaf total used for aggregate watch state, or null when this
|
||
/// item is a leaf or has no authoritative total. A season's direct children
|
||
/// are episodes, so [childCount] is a valid fallback there; it is not valid
|
||
/// for shows, whose direct children are seasons.
|
||
int? get leafWatchTotal {
|
||
if (!kind.usesLeafWatchCounts) return null;
|
||
final total = leafCount ?? (kind == MediaKind.season ? childCount : null);
|
||
return total != null && total > 0 ? total : null;
|
||
}
|
||
|
||
/// Normalized aggregate completion in the inclusive range 0–1.
|
||
double? get leafWatchFraction {
|
||
final total = leafWatchTotal;
|
||
final viewed = viewedLeafCount;
|
||
if (total == null || viewed == null) return null;
|
||
if (viewed <= 0) return 0;
|
||
if (viewed >= total) return 1;
|
||
return viewed / total;
|
||
}
|
||
|
||
/// Whether this container has some but not all leaves watched.
|
||
bool get isPartiallyWatched {
|
||
final fraction = leafWatchFraction;
|
||
return fraction != null && fraction > 0 && fraction < 1;
|
||
}
|
||
|
||
/// Whether the item is fully watched. Container kinds use positive
|
||
/// aggregate leaf totals; leaf kinds use their own [viewCount].
|
||
bool get isWatched {
|
||
final fraction = leafWatchFraction;
|
||
if (fraction != null) return fraction >= 1;
|
||
return viewCount != null && viewCount! > 0;
|
||
}
|
||
|
||
/// Unwatched leaf count for container badges. Falls back to Jellyfin's
|
||
/// `UserData.UnplayedItemCount` when leaf totals weren't requested
|
||
/// (e.g. the folder tree's slim field set).
|
||
int? get unwatchedCount {
|
||
if (!kind.usesLeafWatchCounts) return null;
|
||
|
||
final total = leafWatchTotal;
|
||
final viewed = viewedLeafCount;
|
||
if (total != null && viewed != null) {
|
||
if (viewed <= 0) return total;
|
||
if (viewed >= total) return 0;
|
||
return total - viewed;
|
||
}
|
||
|
||
final userData = raw?['UserData'];
|
||
final unwatched = userData is Map<String, dynamic> ? flexibleInt(userData['UnplayedItemCount']) : null;
|
||
return unwatched != null && unwatched >= 0 ? unwatched : null;
|
||
}
|
||
|
||
/// Copy with the watched flag applied so [isWatched] reflects it for every
|
||
/// kind. This is the single mutation seam used by watch-state overlays.
|
||
MediaItem withWatchedFlag(bool isWatched) {
|
||
var updated = copyWith(viewCount: isWatched ? 1 : 0);
|
||
final total = leafWatchTotal;
|
||
if (total != null) {
|
||
updated = updated.copyWith(viewedLeafCount: isWatched ? total : 0);
|
||
} else if (!kind.usesLeafWatchCounts && viewedLeafCount != null) {
|
||
updated = updated.copyWith(viewedLeafCount: null);
|
||
}
|
||
return updated;
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
|
||
/// Track number within its disc, for [MediaKind.track] items.
|
||
int? get trackNumber => kind == MediaKind.track ? index : null;
|
||
|
||
/// Disc number for [MediaKind.track] items (Plex `parentIndex`, Jellyfin
|
||
/// `ParentIndexNumber`). Null/1 on single-disc albums.
|
||
int? get discNumber => kind == MediaKind.track ? parentIndex : null;
|
||
|
||
/// Album title for music items: a track's parent, an album's own title.
|
||
String? get albumTitle => switch (kind) {
|
||
MediaKind.track => parentTitle,
|
||
MediaKind.album => title,
|
||
_ => null,
|
||
};
|
||
|
||
/// Release year for music items. Track mappers normalize the containing
|
||
/// album's year into [year] when the backend exposes it as parent metadata.
|
||
int? get albumYear => kind == MediaKind.track || kind == MediaKind.album ? year : null;
|
||
|
||
/// Album-artist name for music items: a track's grandparent, an album's
|
||
/// parent.
|
||
String? get albumArtistTitle => switch (kind) {
|
||
MediaKind.track => grandparentTitle,
|
||
MediaKind.album => parentTitle,
|
||
_ => null,
|
||
};
|
||
|
||
/// Performing artist of a track. Falls back to [albumArtistTitle] — both
|
||
/// backends only populate a separate value when it differs (Plex stores a
|
||
/// compilation track's own artist in `originalTitle`; the Jellyfin mapper
|
||
/// mirrors that convention from `Artists`).
|
||
String? get trackArtistTitle => kind == MediaKind.track ? (originalTitle ?? albumArtistTitle) : null;
|
||
|
||
/// Plex-only edition label. Jellyfin returns null.
|
||
String? get editionTitle => null;
|
||
|
||
/// Returns the appropriate poster path based on episode poster mode.
|
||
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;
|
||
}
|
||
|
||
if (kind == MediaKind.clip) return thumbPath ?? artPath;
|
||
|
||
return thumbPath;
|
||
}
|
||
|
||
/// Secondary poster path to try when [posterThumb] returns an image URL that
|
||
/// exists syntactically but the server cannot serve it.
|
||
String? posterThumbFallback({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) {
|
||
final String? fallback;
|
||
if (kind == MediaKind.track) {
|
||
fallback = parentThumbPath;
|
||
} else if (kind == MediaKind.episode && mode == EpisodePosterMode.seasonPoster) {
|
||
fallback = grandparentThumbPath ?? thumbPath;
|
||
} else {
|
||
return null;
|
||
}
|
||
return fallback != null && fallback != posterThumb(mode: mode, mixedHubContext: mixedHubContext) ? fallback : null;
|
||
}
|
||
|
||
/// True when the item should render in 16:9.
|
||
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;
|
||
}
|
||
|
||
/// The card silhouette this item renders with. Music items (artist/album/
|
||
/// track) are square; everything else folds in the [usesWideAspectRatio]
|
||
/// wide-vs-poster decision, so the two can never disagree.
|
||
CardShape cardShape(EpisodePosterMode mode, {bool mixedHubContext = false}) {
|
||
if (kind.isMusic) return CardShape.square;
|
||
return usesWideAspectRatio(mode, mixedHubContext: mixedHubContext) ? CardShape.wide : CardShape.poster;
|
||
}
|
||
|
||
/// Every own-item backdrop in Jellyfin display order. Older persisted
|
||
/// objects and backends with one backdrop fall back to [artPath].
|
||
List<String> get resolvedBackdropPaths {
|
||
final paths = backdropPaths;
|
||
if (paths != null && paths.isNotEmpty) return paths;
|
||
final primary = artPath;
|
||
return primary == null || primary.isEmpty ? const [] : [primary];
|
||
}
|
||
|
||
/// Every inherited series backdrop in Jellyfin display order. Older
|
||
/// persisted objects fall back to [grandparentArtPath].
|
||
List<String> get resolvedGrandparentBackdropPaths {
|
||
final paths = grandparentBackdropPaths;
|
||
if (paths != null && paths.isNotEmpty) return paths;
|
||
final primary = grandparentArtPath;
|
||
return primary == null || primary.isEmpty ? const [] : [primary];
|
||
}
|
||
|
||
/// Backdrops eligible for rotation. Episodes prefer inherited series art;
|
||
/// other kinds rotate only their own artwork.
|
||
List<String> get heroBackdropPaths {
|
||
if (kind == MediaKind.episode) {
|
||
final inherited = resolvedGrandparentBackdropPaths;
|
||
if (inherited.isNotEmpty) return inherited;
|
||
}
|
||
return resolvedBackdropPaths;
|
||
}
|
||
|
||
/// The backdrops a hero may rotate through in a container of
|
||
/// [containerAspectRatio].
|
||
///
|
||
/// `CyclingMediaBackdrop` cycles its rotation set indefinitely and reaches a
|
||
/// fallback path only once every rotating path has failed to load, so the
|
||
/// rotation set must hold whatever [heroArtCandidates] prefers — otherwise
|
||
/// one servable wide backdrop hides the square background for good and a
|
||
/// near-square hero is stuck with a cropped 16:9 frame. Such containers
|
||
/// therefore rotate the square background alone, which is to say they hold
|
||
/// still.
|
||
List<String> heroRotationPaths({required double containerAspectRatio}) {
|
||
if (containerAspectRatio < _squareHeroAspectRatio) {
|
||
final square = backgroundSquarePath;
|
||
if (square != null && square.isNotEmpty) return [square];
|
||
}
|
||
return heroBackdropPaths;
|
||
}
|
||
|
||
/// Returns hero art candidates in display-preference order.
|
||
List<String> heroArtCandidates({required double containerAspectRatio}) {
|
||
final own = resolvedBackdropPaths;
|
||
final inherited = resolvedGrandparentBackdropPaths;
|
||
final isNearSquare = containerAspectRatio < _squareHeroAspectRatio;
|
||
final preferred = switch (kind) {
|
||
MediaKind.episode when isNearSquare => <String?>[backgroundSquarePath, ...inherited, ...own],
|
||
MediaKind.episode => <String?>[...inherited, ...own, backgroundSquarePath],
|
||
_ when isNearSquare => <String?>[backgroundSquarePath, ...own],
|
||
_ => <String?>[...own, backgroundSquarePath],
|
||
};
|
||
|
||
final candidates = <String>[];
|
||
for (final path in preferred) {
|
||
if (path == null || path.isEmpty || candidates.contains(path)) continue;
|
||
candidates.add(path);
|
||
}
|
||
return candidates;
|
||
}
|
||
}
|
||
|
||
/// The silhouette a media card renders with: 2:3 posters, 16:9 wide
|
||
/// thumbnails (episodes/clips), or 1:1 squares (music artwork; artists clip
|
||
/// to a circle). Resolved per item via [MediaItem.cardShape].
|
||
enum CardShape { poster, wide, square }
|
||
|
||
MediaKind _mediaKindFromJson(Object? raw) => MediaKind.fromString(raw as String?);
|
||
|
||
String _mediaKindToJson(MediaKind kind) => kind.id;
|
||
|
||
List<String>? _mediaItemStringList(Object? raw) => stringListFromRaw(raw, stringify: true);
|
||
|
||
List<MediaRole>? _mediaItemRolesFromJson(Object? raw) {
|
||
return raw is List
|
||
? [
|
||
for (final role in raw)
|
||
if (role is Map<String, dynamic>) MediaRole.fromJson(role),
|
||
]
|
||
: null;
|
||
}
|
||
|
||
List<MediaVersion>? _mediaItemVersionsFromJson(Object? raw) {
|
||
return raw is List
|
||
? [
|
||
for (final version in raw)
|
||
if (version is Map<String, dynamic>) MediaVersion.fromJson(version),
|
||
]
|
||
: null;
|
||
}
|
||
|
||
List<MediaRatingSource>? _mediaItemRatingsFromJson(Object? raw) {
|
||
return raw is List
|
||
? [
|
||
for (final rating in raw)
|
||
if (rating is Map<String, Object?>) ?MediaRatingSource.fromJson(rating),
|
||
]
|
||
: null;
|
||
}
|
||
|
||
Map<String, Object?>? _mediaItemRawFromJson(Object? raw) => raw is Map ? Map<String, Object?>.from(raw) : null;
|