Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
790 lines
30 KiB
Dart
790 lines
30 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_browser_dialect.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.
|
||
///
|
||
/// [JellyfinMediaItem] backs both MediaBrowser-family backends: Jellyfin and
|
||
/// Emby return field-identical `BaseItemDto`s, so they share one variant and
|
||
/// carry [JellyfinMediaItem.dialect] to tell them apart.
|
||
@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 || MediaBackend.emby => JellyfinMediaItem(
|
||
dialect: backend.dialect!,
|
||
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 MediaBrowser
|
||
/// server — Jellyfin or Emby, discriminated by [JellyfinMediaItem.dialect].
|
||
@FreezedUnionValue('jellyfin')
|
||
@JsonSerializable(includeIfNull: false, explicitToJson: true)
|
||
const factory MediaItem.jellyfin({
|
||
/// Which MediaBrowser dialect produced this item.
|
||
///
|
||
/// Not serialized on its own: [MediaItem.toJson] already writes the
|
||
/// resolved [backend] id under the union key, and [MediaItem.fromJson]
|
||
/// restores the dialect from it. Keeping one discriminator on the wire
|
||
/// avoids the two disagreeing on a stale cache row.
|
||
@JsonKey(includeToJson: false, includeFromJson: false)
|
||
@Default(MediaBrowserDialect.jellyfin)
|
||
MediaBrowserDialect dialect,
|
||
@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(:final dialect) => dialect.backend,
|
||
};
|
||
|
||
/// 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),
|
||
MediaBackend.emby => _$JellyfinMediaItemFromJson(json).copyWith(dialect: MediaBrowserDialect.emby),
|
||
};
|
||
}
|
||
|
||
Map<String, dynamic> toJson() {
|
||
return switch (this) {
|
||
final PlexMediaItem item => {'backend': MediaBackend.plex.id, ..._$PlexMediaItemToJson(item)},
|
||
final JellyfinMediaItem item => {'backend': item.dialect.backend.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;
|