refactor(media): migrate media item to freezed

This commit is contained in:
edde746
2026-05-11 07:01:46 +02:00
parent 4252bd88e9
commit f9615491b3
44 changed files with 4753 additions and 2866 deletions
File diff suppressed because it is too large Load Diff
+20 -17
View File
@@ -14,22 +14,25 @@ MediaFilter _$MediaFilterFromJson(Map<String, dynamic> json) => MediaFilter(
type: json['type'] as String? ?? 'filter',
);
Map<String, dynamic> _$MediaFilterToJson(MediaFilter instance) => <String, dynamic>{
'filter': instance.filter,
'filterType': instance.filterType,
'key': instance.key,
'title': instance.title,
'type': instance.type,
};
Map<String, dynamic> _$MediaFilterToJson(MediaFilter instance) =>
<String, dynamic>{
'filter': instance.filter,
'filterType': instance.filterType,
'key': instance.key,
'title': instance.title,
'type': instance.type,
};
MediaFilterValue _$MediaFilterValueFromJson(Map<String, dynamic> json) => MediaFilterValue(
key: json['key'] as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String?,
);
MediaFilterValue _$MediaFilterValueFromJson(Map<String, dynamic> json) =>
MediaFilterValue(
key: json['key'] as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String?,
);
Map<String, dynamic> _$MediaFilterValueToJson(MediaFilterValue instance) => <String, dynamic>{
'key': instance.key,
'title': instance.title,
'type': ?instance.type,
};
Map<String, dynamic> _$MediaFilterValueToJson(MediaFilterValue instance) =>
<String, dynamic>{
'key': instance.key,
'title': instance.title,
'type': ?instance.type,
};
+495 -5
View File
@@ -1,13 +1,503 @@
// ignore_for_file: invalid_annotation_target
import 'package:freezed_annotation/freezed_annotation.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';
part 'media_item/base.dart';
part 'media_item/json.dart';
part 'media_item/plex.dart';
part 'media_item/jellyfin.dart';
part 'media_item.freezed.dart';
part 'media_item.g.dart';
/// 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,
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? 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,
),
};
}
/// 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,
String? thumbPath,
String? artPath,
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? audienceRating,
@JsonKey(fromJson: flexibleDouble) double? userRating,
String? ratingImage,
String? audienceRatingImage,
@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,
@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,
String? thumbPath,
String? artPath,
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,
@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,
@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!, 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. 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;
}
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}) {
if (kind != MediaKind.episode || mode != EpisodePosterMode.seasonPoster) return null;
final fallback = grandparentThumbPath ?? thumbPath;
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;
}
/// Returns the best hero art path based on the container's aspect ratio.
String? heroArt({required double containerAspectRatio}) {
final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio);
if (candidates.isEmpty) return null;
return candidates.first;
}
/// Returns hero art candidates in display-preference order.
List<String> heroArtCandidates({required double containerAspectRatio}) {
final preferred = containerAspectRatio < 1.39 ? [backgroundSquarePath, artPath] : [artPath, backgroundSquarePath];
final candidates = <String>[];
for (final path in preferred) {
if (path == null || path.isEmpty || candidates.contains(path)) continue;
candidates.add(path);
}
return candidates;
}
}
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;
}
Map<String, Object?>? _mediaItemRawFromJson(Object? raw) => raw is Map ? Map<String, Object?>.from(raw) : null;
File diff suppressed because one or more lines are too long
+259
View File
@@ -0,0 +1,259 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_item.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PlexMediaItem _$PlexMediaItemFromJson(Map<String, dynamic> json) =>
PlexMediaItem(
id: readStringField(json, 'id') as String? ?? '',
kind: _mediaKindFromJson(json['kind']),
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?,
editionTitle: json['editionTitle'] 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']),
audienceRating: flexibleDouble(json['audienceRating']),
userRating: flexibleDouble(json['userRating']),
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
genres: _mediaItemStringList(json['genres']),
directors: _mediaItemStringList(json['directors']),
writers: _mediaItemStringList(json['writers']),
producers: _mediaItemStringList(json['producers']),
countries: _mediaItemStringList(json['countries']),
collections: _mediaItemStringList(json['collections']),
labels: _mediaItemStringList(json['labels']),
styles: _mediaItemStringList(json['styles']),
moods: _mediaItemStringList(json['moods']),
roles: _mediaItemRolesFromJson(json['roles']),
mediaVersions: _mediaItemVersionsFromJson(json['mediaVersions']),
libraryId: json['libraryId'] as String?,
libraryTitle: json['libraryTitle'] as String?,
audioLanguage: json['audioLanguage'] as String?,
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: json['serverId'] as String?,
serverName: json['serverName'] as String?,
raw: _mediaItemRawFromJson(json['raw']),
);
Map<String, dynamic> _$PlexMediaItemToJson(PlexMediaItem instance) =>
<String, dynamic>{
'id': instance.id,
'kind': _mediaKindToJson(instance.kind),
'guid': ?instance.guid,
'title': ?instance.title,
'titleSort': ?instance.titleSort,
'summary': ?instance.summary,
'tagline': ?instance.tagline,
'originalTitle': ?instance.originalTitle,
'editionTitle': ?instance.editionTitle,
'studio': ?instance.studio,
'year': ?instance.year,
'originallyAvailableAt': ?instance.originallyAvailableAt,
'contentRating': ?instance.contentRating,
'parentId': ?instance.parentId,
'parentTitle': ?instance.parentTitle,
'parentThumbPath': ?instance.parentThumbPath,
'parentIndex': ?instance.parentIndex,
'index': ?instance.index,
'grandparentId': ?instance.grandparentId,
'grandparentTitle': ?instance.grandparentTitle,
'grandparentThumbPath': ?instance.grandparentThumbPath,
'grandparentArtPath': ?instance.grandparentArtPath,
'thumbPath': ?instance.thumbPath,
'artPath': ?instance.artPath,
'clearLogoPath': ?instance.clearLogoPath,
'backgroundSquarePath': ?instance.backgroundSquarePath,
'durationMs': ?instance.durationMs,
'viewOffsetMs': ?instance.viewOffsetMs,
'viewCount': ?instance.viewCount,
'lastViewedAt': ?instance.lastViewedAt,
'leafCount': ?instance.leafCount,
'viewedLeafCount': ?instance.viewedLeafCount,
'childCount': ?instance.childCount,
'addedAt': ?instance.addedAt,
'updatedAt': ?instance.updatedAt,
'rating': ?instance.rating,
'audienceRating': ?instance.audienceRating,
'userRating': ?instance.userRating,
'ratingImage': ?instance.ratingImage,
'audienceRatingImage': ?instance.audienceRatingImage,
'genres': ?instance.genres,
'directors': ?instance.directors,
'writers': ?instance.writers,
'producers': ?instance.producers,
'countries': ?instance.countries,
'collections': ?instance.collections,
'labels': ?instance.labels,
'styles': ?instance.styles,
'moods': ?instance.moods,
'roles': ?instance.roles?.map((e) => e.toJson()).toList(),
'mediaVersions': ?instance.mediaVersions?.map((e) => e.toJson()).toList(),
'libraryId': ?instance.libraryId,
'libraryTitle': ?instance.libraryTitle,
'audioLanguage': ?instance.audioLanguage,
'subtitleLanguage': ?instance.subtitleLanguage,
'subtitleMode': ?instance.subtitleMode,
'trailerKey': ?instance.trailerKey,
'playlistItemId': ?instance.playlistItemId,
'playQueueItemId': ?instance.playQueueItemId,
'subtype': ?instance.subtype,
'extraType': ?instance.extraType,
'serverId': ?instance.serverId,
'serverName': ?instance.serverName,
'raw': ?instance.raw,
};
JellyfinMediaItem _$JellyfinMediaItemFromJson(Map<String, dynamic> json) =>
JellyfinMediaItem(
id: readStringField(json, 'id') as String? ?? '',
kind: _mediaKindFromJson(json['kind']),
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: _mediaItemStringList(json['genres']),
directors: _mediaItemStringList(json['directors']),
writers: _mediaItemStringList(json['writers']),
producers: _mediaItemStringList(json['producers']),
countries: _mediaItemStringList(json['countries']),
collections: _mediaItemStringList(json['collections']),
labels: _mediaItemStringList(json['labels']),
styles: _mediaItemStringList(json['styles']),
moods: _mediaItemStringList(json['moods']),
roles: _mediaItemRolesFromJson(json['roles']),
mediaVersions: _mediaItemVersionsFromJson(json['mediaVersions']),
libraryId: json['libraryId'] as String?,
libraryTitle: json['libraryTitle'] as String?,
audioLanguage: json['audioLanguage'] as String?,
playlistItemId: json['playlistItemId'] as String?,
serverId: json['serverId'] as String?,
serverName: json['serverName'] as String?,
raw: _mediaItemRawFromJson(json['raw']),
);
Map<String, dynamic> _$JellyfinMediaItemToJson(JellyfinMediaItem instance) =>
<String, dynamic>{
'id': instance.id,
'kind': _mediaKindToJson(instance.kind),
'guid': ?instance.guid,
'title': ?instance.title,
'titleSort': ?instance.titleSort,
'summary': ?instance.summary,
'tagline': ?instance.tagline,
'originalTitle': ?instance.originalTitle,
'studio': ?instance.studio,
'year': ?instance.year,
'originallyAvailableAt': ?instance.originallyAvailableAt,
'contentRating': ?instance.contentRating,
'parentId': ?instance.parentId,
'parentTitle': ?instance.parentTitle,
'parentThumbPath': ?instance.parentThumbPath,
'parentIndex': ?instance.parentIndex,
'index': ?instance.index,
'grandparentId': ?instance.grandparentId,
'grandparentTitle': ?instance.grandparentTitle,
'grandparentThumbPath': ?instance.grandparentThumbPath,
'grandparentArtPath': ?instance.grandparentArtPath,
'thumbPath': ?instance.thumbPath,
'artPath': ?instance.artPath,
'clearLogoPath': ?instance.clearLogoPath,
'backgroundSquarePath': ?instance.backgroundSquarePath,
'durationMs': ?instance.durationMs,
'viewOffsetMs': ?instance.viewOffsetMs,
'viewCount': ?instance.viewCount,
'lastViewedAt': ?instance.lastViewedAt,
'leafCount': ?instance.leafCount,
'viewedLeafCount': ?instance.viewedLeafCount,
'childCount': ?instance.childCount,
'addedAt': ?instance.addedAt,
'updatedAt': ?instance.updatedAt,
'rating': ?instance.rating,
'userRating': ?instance.userRating,
'genres': ?instance.genres,
'directors': ?instance.directors,
'writers': ?instance.writers,
'producers': ?instance.producers,
'countries': ?instance.countries,
'collections': ?instance.collections,
'labels': ?instance.labels,
'styles': ?instance.styles,
'moods': ?instance.moods,
'roles': ?instance.roles?.map((e) => e.toJson()).toList(),
'mediaVersions': ?instance.mediaVersions?.map((e) => e.toJson()).toList(),
'libraryId': ?instance.libraryId,
'libraryTitle': ?instance.libraryTitle,
'audioLanguage': ?instance.audioLanguage,
'playlistItemId': ?instance.playlistItemId,
'serverId': ?instance.serverId,
'serverName': ?instance.serverName,
'raw': ?instance.raw,
};
-682
View File
@@ -1,682 +0,0 @@
part of '../media_item.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;
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;
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;
final String? thumbPath;
final String? artPath;
final String? clearLogoPath;
final String? backgroundSquarePath;
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;
final double? rating;
final double? userRating;
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;
final List<MediaVersion>? mediaVersions;
/// Backend-opaque library/section id this item belongs to.
final String? libraryId;
final String? libraryTitle;
/// 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;
final String? serverId;
final String? serverName;
/// 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].
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;
}
/// 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}) {
if (kind != MediaKind.episode || mode != EpisodePosterMode.seasonPoster) return null;
final fallback = grandparentThumbPath ?? thumbPath;
return fallback != null && fallback != posterThumb(mode: mode, mixedHubContext: mixedHubContext) ? fallback : null;
}
/// 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}) {
final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio);
if (candidates.isEmpty) return null;
return candidates.first;
}
/// Returns hero art candidates in display-preference order.
/// Near-square containers prefer square art, then fall back to wide cover art.
List<String> heroArtCandidates({required double containerAspectRatio}) {
// Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39
final preferred = containerAspectRatio < 1.39 ? [backgroundSquarePath, artPath] : [artPath, backgroundSquarePath];
final candidates = <String>[];
for (final path in preferred) {
if (path == null || path.isEmpty || candidates.contains(path)) continue;
candidates.add(path);
}
return candidates;
}
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);
}
}
-258
View File
@@ -1,258 +0,0 @@
part of '../media_item.dart';
/// 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,
);
}
}
-200
View File
@@ -1,200 +0,0 @@
part of '../media_item.dart';
/// 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,
);
}
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 [],
);
}
-351
View File
@@ -1,351 +0,0 @@
part of '../media_item.dart';
/// 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,
);
}
}
+16
View File
@@ -1,10 +1,17 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'media_stream.dart';
part 'media_part.g.dart';
/// One physical file part of a [MediaVersion]. A movie typically has a single
/// part; some Plex multi-part files (CD1/CD2) and DVD/BluRay rips can have
/// several. Jellyfin items always map to a single part per media source.
@JsonSerializable(includeIfNull: false)
class MediaPart {
/// Backend-opaque part identifier.
@JsonKey(fromJson: _stringFromJson)
final String id;
/// Backend-specific path used to construct a direct stream URL — e.g. Plex's
@@ -13,11 +20,14 @@ class MediaPart {
/// appending auth.
final String? streamPath;
@JsonKey(fromJson: flexibleInt)
final int? sizeBytes;
final String? container;
@JsonKey(fromJson: flexibleInt)
final int? durationMs;
final bool? accessible;
final bool? exists;
@JsonKey(includeFromJson: false, includeToJson: false)
final List<MediaStream> streams;
const MediaPart({
@@ -31,7 +41,13 @@ class MediaPart {
this.streams = const [],
});
factory MediaPart.fromJson(Map<String, dynamic> json) => _$MediaPartFromJson(json);
Map<String, dynamic> toJson() => _$MediaPartToJson(this);
/// Defaults to true when fields are absent. Plex sets these only when the
/// metadata request includes `checkFiles=1`.
bool get isPlayable => accessible != false && exists != false;
}
String _stringFromJson(Object? raw) => (raw ?? '').toString();
+27
View File
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_part.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaPart _$MediaPartFromJson(Map<String, dynamic> json) => MediaPart(
id: _stringFromJson(json['id']),
streamPath: json['streamPath'] as String?,
sizeBytes: flexibleInt(json['sizeBytes']),
container: json['container'] as String?,
durationMs: flexibleInt(json['durationMs']),
accessible: json['accessible'] as bool?,
exists: json['exists'] as bool?,
);
Map<String, dynamic> _$MediaPartToJson(MediaPart instance) => <String, dynamic>{
'id': instance.id,
'streamPath': ?instance.streamPath,
'sizeBytes': ?instance.sizeBytes,
'container': ?instance.container,
'durationMs': ?instance.durationMs,
'accessible': ?instance.accessible,
'exists': ?instance.exists,
};
+12
View File
@@ -1,9 +1,21 @@
import 'package:json_annotation/json_annotation.dart';
part 'media_role.g.dart';
/// A cast or crew member attached to a media item.
@JsonSerializable(includeIfNull: false)
class MediaRole {
final String? id;
@JsonKey(fromJson: _stringFromJson)
final String tag;
final String? role;
final String? thumbPath;
const MediaRole({this.id, required this.tag, this.role, this.thumbPath});
factory MediaRole.fromJson(Map<String, dynamic> json) => _$MediaRoleFromJson(json);
Map<String, dynamic> toJson() => _$MediaRoleToJson(this);
}
String _stringFromJson(Object? raw) => (raw ?? '').toString();
+21
View File
@@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_role.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaRole _$MediaRoleFromJson(Map<String, dynamic> json) => MediaRole(
id: json['id'] as String?,
tag: _stringFromJson(json['tag']),
role: json['role'] as String?,
thumbPath: json['thumbPath'] as String?,
);
Map<String, dynamic> _$MediaRoleToJson(MediaRole instance) => <String, dynamic>{
'id': ?instance.id,
'tag': instance.tag,
'role': ?instance.role,
'thumbPath': ?instance.thumbPath,
};
+28
View File
@@ -1,7 +1,12 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/codec_utils.dart';
import '../utils/formatters.dart';
import '../utils/json_utils.dart';
import 'media_part.dart';
part 'media_version.g.dart';
/// Convert backend bitrates reported in bits-per-second to app-standard kbps.
int? bitrateKbpsFromBps(int? bps) {
if (bps == null || bps <= 0) return null;
@@ -19,15 +24,21 @@ String _videoResolutionDisplayLabel(String resolution) {
/// A single media variant available for an item — represents one quality level
/// or transcode profile of the underlying file. An item with multiple versions
/// (e.g. 4K + 1080p re-encode) exposes one [MediaVersion] per option.
@JsonSerializable(includeIfNull: false, explicitToJson: true)
class MediaVersion {
/// Backend-opaque version identifier.
@JsonKey(fromJson: _stringFromJson)
final String id;
@JsonKey(fromJson: flexibleInt)
final int? width;
@JsonKey(fromJson: flexibleInt)
final int? height;
final String? videoResolution; // "1080", "4k", "sd"
final String? videoCodec;
@JsonKey(fromJson: flexibleInt)
final int? bitrate;
final String? container;
@JsonKey(fromJson: _partsFromJson, toJson: _partsToJson)
final List<MediaPart> parts;
/// Human-readable name for this version (e.g. "Director's Cut").
@@ -48,6 +59,10 @@ class MediaVersion {
this.name,
});
factory MediaVersion.fromJson(Map<String, dynamic> json) => _$MediaVersionFromJson(json);
Map<String, dynamic> toJson() => _$MediaVersionToJson(this);
/// Defaults to true when file-access fields are absent. Plex only populates
/// them when metadata is fetched with `checkFiles=1`.
bool get isPlayable => parts.isEmpty || parts.first.isPlayable;
@@ -123,3 +138,16 @@ class MediaVersion {
return null;
}
}
String _stringFromJson(Object? raw) => (raw ?? '').toString();
List<MediaPart> _partsFromJson(Object? raw) {
return raw is List
? [
for (final part in raw)
if (part is Map<String, dynamic>) MediaPart.fromJson(part),
]
: const [];
}
List<Map<String, dynamic>> _partsToJson(List<MediaPart> parts) => [for (final part in parts) part.toJson()];
+32
View File
@@ -0,0 +1,32 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_version.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaVersion _$MediaVersionFromJson(Map<String, dynamic> json) => MediaVersion(
id: _stringFromJson(json['id']),
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?,
parts: json['parts'] == null ? const [] : _partsFromJson(json['parts']),
name: json['name'] as String?,
);
Map<String, dynamic> _$MediaVersionToJson(MediaVersion instance) =>
<String, dynamic>{
'id': instance.id,
'width': ?instance.width,
'height': ?instance.height,
'videoResolution': ?instance.videoResolution,
'videoCodec': ?instance.videoCodec,
'bitrate': ?instance.bitrate,
'container': ?instance.container,
'parts': _partsToJson(instance.parts),
'name': ?instance.name,
};
@@ -10,43 +10,61 @@ RemoteDevice _$RemoteDeviceFromJson(Map<String, dynamic> json) => RemoteDevice(
id: json['id'] as String,
name: json['name'] as String,
platform: json['platform'] as String,
connectedAt: json['connectedAt'] == null ? null : DateTime.parse(json['connectedAt'] as String),
capabilities: (json['capabilities'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as bool)),
);
Map<String, dynamic> _$RemoteDeviceToJson(RemoteDevice instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'platform': instance.platform,
'connectedAt': instance.connectedAt.toIso8601String(),
'capabilities': instance.capabilities,
};
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) => RemoteSession(
role: $enumDecode(_$RemoteSessionRoleEnumMap, json['role'], unknownValue: RemoteSessionRole.remote),
status:
$enumDecodeNullable(
_$RemoteSessionStatusEnumMap,
json['status'],
unknownValue: RemoteSessionStatus.disconnected,
) ??
RemoteSessionStatus.disconnected,
connectedDevice: json['connectedDevice'] == null
connectedAt: json['connectedAt'] == null
? null
: RemoteDevice.fromJson(json['connectedDevice'] as Map<String, dynamic>),
createdAt: json['createdAt'] == null ? null : DateTime.parse(json['createdAt'] as String),
errorMessage: json['errorMessage'] as String?,
: DateTime.parse(json['connectedAt'] as String),
capabilities: (json['capabilities'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as bool),
),
);
Map<String, dynamic> _$RemoteSessionToJson(RemoteSession instance) => <String, dynamic>{
'role': _$RemoteSessionRoleEnumMap[instance.role]!,
'status': _$RemoteSessionStatusEnumMap[instance.status]!,
'connectedDevice': instance.connectedDevice,
'createdAt': instance.createdAt.toIso8601String(),
'errorMessage': instance.errorMessage,
};
Map<String, dynamic> _$RemoteDeviceToJson(RemoteDevice instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'platform': instance.platform,
'connectedAt': instance.connectedAt.toIso8601String(),
'capabilities': instance.capabilities,
};
const _$RemoteSessionRoleEnumMap = {RemoteSessionRole.host: 'host', RemoteSessionRole.remote: 'remote'};
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) =>
RemoteSession(
role: $enumDecode(
_$RemoteSessionRoleEnumMap,
json['role'],
unknownValue: RemoteSessionRole.remote,
),
status:
$enumDecodeNullable(
_$RemoteSessionStatusEnumMap,
json['status'],
unknownValue: RemoteSessionStatus.disconnected,
) ??
RemoteSessionStatus.disconnected,
connectedDevice: json['connectedDevice'] == null
? null
: RemoteDevice.fromJson(
json['connectedDevice'] as Map<String, dynamic>,
),
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
errorMessage: json['errorMessage'] as String?,
);
Map<String, dynamic> _$RemoteSessionToJson(RemoteSession instance) =>
<String, dynamic>{
'role': _$RemoteSessionRoleEnumMap[instance.role]!,
'status': _$RemoteSessionStatusEnumMap[instance.status]!,
'connectedDevice': instance.connectedDevice,
'createdAt': instance.createdAt.toIso8601String(),
'errorMessage': instance.errorMessage,
};
const _$RemoteSessionRoleEnumMap = {
RemoteSessionRole.host: 'host',
RemoteSessionRole.remote: 'remote',
};
const _$RemoteSessionStatusEnumMap = {
RemoteSessionStatus.disconnected: 'disconnected',
+22 -20
View File
@@ -6,24 +6,26 @@ part of 'livetv_channel.dart';
// JsonSerializableGenerator
// **************************************************************************
LiveTvChannel _$LiveTvChannelFromJson(Map<String, dynamic> json) => LiveTvChannel(
key: _readChannelKey(json, 'key') as String,
identifier: _readChannelIdentifier(json, 'identifier') as String?,
callSign: json['callSign'] as String?,
title: _readChannelTitle(json, 'title') as String?,
thumb: json['thumb'] as String?,
art: json['art'] as String?,
number: _readChannelNumber(json, 'number') as String?,
hd: json['hd'] == null ? false : flexibleBool(json['hd']),
lineup: json['lineup'] as String?,
slug: json['slug'] as String?,
drm: flexibleBool(json['drm']),
);
LiveTvChannel _$LiveTvChannelFromJson(Map<String, dynamic> json) =>
LiveTvChannel(
key: _readChannelKey(json, 'key') as String,
identifier: _readChannelIdentifier(json, 'identifier') as String?,
callSign: json['callSign'] as String?,
title: _readChannelTitle(json, 'title') as String?,
thumb: json['thumb'] as String?,
art: json['art'] as String?,
number: _readChannelNumber(json, 'number') as String?,
hd: json['hd'] == null ? false : flexibleBool(json['hd']),
lineup: json['lineup'] as String?,
slug: json['slug'] as String?,
drm: flexibleBool(json['drm']),
);
FavoriteChannel _$FavoriteChannelFromJson(Map<String, dynamic> json) => FavoriteChannel(
source: json['source'] as String? ?? '',
id: _readFavoriteChannelId(json, 'id') as String,
title: json['title'] as String?,
thumb: json['thumb'] as String?,
vcn: json['vcn'] as String?,
);
FavoriteChannel _$FavoriteChannelFromJson(Map<String, dynamic> json) =>
FavoriteChannel(
source: json['source'] as String? ?? '',
id: _readFavoriteChannelId(json, 'id') as String,
title: json['title'] as String?,
thumb: json['thumb'] as String?,
vcn: json['vcn'] as String?,
);
+13 -8
View File
@@ -25,14 +25,19 @@ LiveTvDvr _$LiveTvDvrFromJson(Map<String, dynamic> json) => LiveTvDvr(
sources: json['sources'] as String?,
uri: json['uri'] as String?,
lastSeenAt: flexibleInt(json['lastSeenAt']),
channelMappings: json['ChannelMapping'] == null ? const [] : _parseChannelMappings(json['ChannelMapping']),
settings: json['Setting'] == null ? const [] : _parseSettings(json['Setting']),
channelMappings: json['ChannelMapping'] == null
? const []
: _parseChannelMappings(json['ChannelMapping']),
settings: json['Setting'] == null
? const []
: _parseSettings(json['Setting']),
devices: json['Device'] == null ? const [] : _parseRawMaps(json['Device']),
);
ChannelMapping _$ChannelMappingFromJson(Map<String, dynamic> json) => ChannelMapping(
channelKey: json['channelKey'] as String?,
deviceIdentifier: json['deviceIdentifier'] as String?,
enabled: flexibleBool(json['enabled']),
lineupIdentifier: json['lineupIdentifier'] as String?,
);
ChannelMapping _$ChannelMappingFromJson(Map<String, dynamic> json) =>
ChannelMapping(
channelKey: json['channelKey'] as String?,
deviceIdentifier: json['deviceIdentifier'] as String?,
enabled: flexibleBool(json['enabled']),
lineupIdentifier: json['lineupIdentifier'] as String?,
);
+18 -12
View File
@@ -6,19 +6,23 @@ part of 'livetv_lineup.dart';
// JsonSerializableGenerator
// **************************************************************************
LiveTvCountry _$LiveTvCountryFromJson(Map<String, dynamic> json) => LiveTvCountry(
key: json['key'] as String?,
type: json['type'] as String?,
title: json['title'] as String? ?? '',
code: json['code'] as String? ?? '',
language: json['language'] as String?,
languageTitle: json['languageTitle'] as String?,
example: json['example'] as String?,
flavor: flexibleInt(json['flavor']),
);
LiveTvCountry _$LiveTvCountryFromJson(Map<String, dynamic> json) =>
LiveTvCountry(
key: json['key'] as String?,
type: json['type'] as String?,
title: json['title'] as String? ?? '',
code: json['code'] as String? ?? '',
language: json['language'] as String?,
languageTitle: json['languageTitle'] as String?,
example: json['example'] as String?,
flavor: flexibleInt(json['flavor']),
);
LiveTvLanguage _$LiveTvLanguageFromJson(Map<String, dynamic> json) =>
LiveTvLanguage(code: json['code'] as String? ?? '', title: json['title'] as String? ?? '');
LiveTvLanguage(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
);
LiveTvRegion _$LiveTvRegionFromJson(Map<String, dynamic> json) => LiveTvRegion(
key: json['key'] as String? ?? '',
@@ -32,5 +36,7 @@ LiveTvLineup _$LiveTvLineupFromJson(Map<String, dynamic> json) => LiveTvLineup(
title: json['title'] as String?,
lineupType: flexibleInt(json['lineupType']),
location: json['location'] as String?,
channels: json['Channel'] == null ? const [] : _parseChannels(json['Channel']),
channels: json['Channel'] == null
? const []
: _parseChannels(json['Channel']),
);
+6 -5
View File
@@ -6,8 +6,9 @@ part of 'livetv_server_status.dart';
// JsonSerializableGenerator
// **************************************************************************
LiveTvServerStatus _$LiveTvServerStatusFromJson(Map<String, dynamic> json) => LiveTvServerStatus(
liveTvCount: flexibleInt(json['livetv']),
allowTuners: flexibleBoolNullable(json['allowTuners']),
ownerFeatures: json['ownerFeatures'] as String?,
);
LiveTvServerStatus _$LiveTvServerStatusFromJson(Map<String, dynamic> json) =>
LiveTvServerStatus(
liveTvCount: flexibleInt(json['livetv']),
allowTuners: flexibleBoolNullable(json['allowTuners']),
ownerFeatures: json['ownerFeatures'] as String?,
);
+17 -16
View File
@@ -6,19 +6,20 @@ part of 'livetv_session.dart';
// JsonSerializableGenerator
// **************************************************************************
LiveTvSession _$LiveTvSessionFromJson(Map<String, dynamic> json) => LiveTvSession(
sessionID: readStringField(json, 'sessionID') as String? ?? '',
dvrID: readStringField(json, 'dvrID') as String?,
channelIdentifier: json['channelIdentifier'] as String?,
channelCallSign: json['channelCallSign'] as String?,
channelTitle: json['channelTitle'] as String?,
activityUUID: json['activityUUID'] as String?,
currentPosition: flexibleInt(json['currentPosition']),
nextPosition: flexibleInt(json['nextPosition']),
startedAt: flexibleInt(json['startedAt']),
captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']),
grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']),
timeline: _firstMap(json['Timeline']),
airingMetadataItem: _programFromRaw(json['AiringMetadataItem']),
upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']),
);
LiveTvSession _$LiveTvSessionFromJson(Map<String, dynamic> json) =>
LiveTvSession(
sessionID: readStringField(json, 'sessionID') as String? ?? '',
dvrID: readStringField(json, 'dvrID') as String?,
channelIdentifier: json['channelIdentifier'] as String?,
channelCallSign: json['channelCallSign'] as String?,
channelTitle: json['channelTitle'] as String?,
activityUUID: json['activityUUID'] as String?,
currentPosition: flexibleInt(json['currentPosition']),
nextPosition: flexibleInt(json['nextPosition']),
startedAt: flexibleInt(json['startedAt']),
captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']),
grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']),
timeline: _firstMap(json['Timeline']),
airingMetadataItem: _programFromRaw(json['AiringMetadataItem']),
upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']),
);
+17 -16
View File
@@ -6,19 +6,20 @@ part of 'media_grab_operation.dart';
// JsonSerializableGenerator
// **************************************************************************
MediaGrabOperation _$MediaGrabOperationFromJson(Map<String, dynamic> json) => MediaGrabOperation(
mediaSubscriptionID: flexibleInt(json['mediaSubscriptionID']),
mediaIndex: flexibleInt(json['mediaIndex']),
id: json['id'] as String? ?? '',
key: json['key'] as String?,
grabberIdentifier: json['grabberIdentifier'] as String?,
grabberProtocol: json['grabberProtocol'] as String?,
percent: flexibleDouble(json['percent']),
currentSize: flexibleInt(json['currentSize']),
status: json['status'] as String?,
provider: json['provider'] as String?,
rolling: flexibleBoolNullable(json['rolling']),
error: json['error'] as String?,
linkedKey: json['linkedKey'] as String?,
metadata: _metadataFromJson(json['Metadata']),
);
MediaGrabOperation _$MediaGrabOperationFromJson(Map<String, dynamic> json) =>
MediaGrabOperation(
mediaSubscriptionID: flexibleInt(json['mediaSubscriptionID']),
mediaIndex: flexibleInt(json['mediaIndex']),
id: json['id'] as String? ?? '',
key: json['key'] as String?,
grabberIdentifier: json['grabberIdentifier'] as String?,
grabberProtocol: json['grabberProtocol'] as String?,
percent: flexibleDouble(json['percent']),
currentSize: flexibleInt(json['currentSize']),
status: json['status'] as String?,
provider: json['provider'] as String?,
rolling: flexibleBoolNullable(json['rolling']),
error: json['error'] as String?,
linkedKey: json['linkedKey'] as String?,
metadata: _metadataFromJson(json['Metadata']),
);
+26 -19
View File
@@ -12,26 +12,33 @@ MediaGrabber _$MediaGrabberFromJson(Map<String, dynamic> json) => MediaGrabber(
title: json['title'] as String?,
);
MediaGrabberDevice _$MediaGrabberDeviceFromJson(Map<String, dynamic> json) => MediaGrabberDevice(
key: json['key'] as String? ?? '',
uuid: json['uuid'] as String? ?? '',
uri: json['uri'] as String?,
protocol: json['protocol'] as String?,
title: json['title'] as String?,
make: json['make'] as String?,
model: json['model'] as String?,
modelNumber: json['modelNumber'] as String?,
firmware: json['firmware'] as String?,
tuners: flexibleInt(json['tuners']),
sources: json['sources'] as String?,
status: flexibleInt(json['status']),
state: flexibleInt(json['state']),
lastSeenAt: flexibleInt(json['lastSeenAt']),
channelMappings: json['ChannelMapping'] == null ? const [] : _parseChannelMappings(json['ChannelMapping']),
settings: json['Setting'] == null ? const [] : _parseSettings(json['Setting']),
);
MediaGrabberDevice _$MediaGrabberDeviceFromJson(Map<String, dynamic> json) =>
MediaGrabberDevice(
key: json['key'] as String? ?? '',
uuid: json['uuid'] as String? ?? '',
uri: json['uri'] as String?,
protocol: json['protocol'] as String?,
title: json['title'] as String?,
make: json['make'] as String?,
model: json['model'] as String?,
modelNumber: json['modelNumber'] as String?,
firmware: json['firmware'] as String?,
tuners: flexibleInt(json['tuners']),
sources: json['sources'] as String?,
status: flexibleInt(json['status']),
state: flexibleInt(json['state']),
lastSeenAt: flexibleInt(json['lastSeenAt']),
channelMappings: json['ChannelMapping'] == null
? const []
: _parseChannelMappings(json['ChannelMapping']),
settings: json['Setting'] == null
? const []
: _parseSettings(json['Setting']),
);
MediaGrabberDeviceChannel _$MediaGrabberDeviceChannelFromJson(Map<String, dynamic> json) => MediaGrabberDeviceChannel(
MediaGrabberDeviceChannel _$MediaGrabberDeviceChannelFromJson(
Map<String, dynamic> json,
) => MediaGrabberDeviceChannel(
identifier: readStringField(json, 'identifier') as String? ?? '',
key: readStringField(json, 'key') as String?,
name: readStringField(json, 'name') as String?,
+21 -14
View File
@@ -6,26 +6,33 @@ part of 'media_provider_info.dart';
// JsonSerializableGenerator
// **************************************************************************
MediaProviderInfo _$MediaProviderInfoFromJson(Map<String, dynamic> json) => MediaProviderInfo(
id: flexibleInt(json['id']),
parentID: flexibleInt(json['parentID']),
identifier: json['identifier'] as String? ?? '',
providerIdentifier: json['providerIdentifier'] as String?,
title: json['title'] as String?,
types: json['types'] as String?,
protocols: json['protocols'] as String?,
epgSource: json['epgSource'] as String?,
friendlyName: json['friendlyName'] as String?,
features: json['Feature'] == null ? const [] : _parseFeatures(json['Feature']),
);
MediaProviderInfo _$MediaProviderInfoFromJson(Map<String, dynamic> json) =>
MediaProviderInfo(
id: flexibleInt(json['id']),
parentID: flexibleInt(json['parentID']),
identifier: json['identifier'] as String? ?? '',
providerIdentifier: json['providerIdentifier'] as String?,
title: json['title'] as String?,
types: json['types'] as String?,
protocols: json['protocols'] as String?,
epgSource: json['epgSource'] as String?,
friendlyName: json['friendlyName'] as String?,
features: json['Feature'] == null
? const []
: _parseFeatures(json['Feature']),
);
MediaProviderFeature _$MediaProviderFeatureFromJson(Map<String, dynamic> json) => MediaProviderFeature(
MediaProviderFeature _$MediaProviderFeatureFromJson(
Map<String, dynamic> json,
) => MediaProviderFeature(
key: json['key'] as String?,
type: json['type'] as String? ?? '',
flavor: json['flavor'] as String?,
scrobbleKey: json['scrobbleKey'] as String?,
unscrobbleKey: json['unscrobbleKey'] as String?,
directories: json['Directory'] == null ? const [] : _parseRawMaps(json['Directory']),
directories: json['Directory'] == null
? const []
: _parseRawMaps(json['Directory']),
actions: json['Action'] == null ? const [] : _parseRawMaps(json['Action']),
pivots: json['Pivot'] == null ? const [] : _parseRawMaps(json['Pivot']),
);
+47 -35
View File
@@ -6,41 +6,53 @@ part of 'media_subscription.dart';
// JsonSerializableGenerator
// **************************************************************************
SubscriptionTemplate _$SubscriptionTemplateFromJson(Map<String, dynamic> json) => SubscriptionTemplate(
subscriptions: json['MediaSubscription'] == null ? const [] : _parseSubscriptions(json['MediaSubscription']),
SubscriptionTemplate _$SubscriptionTemplateFromJson(
Map<String, dynamic> json,
) => SubscriptionTemplate(
subscriptions: json['MediaSubscription'] == null
? const []
: _parseSubscriptions(json['MediaSubscription']),
);
MediaSubscription _$MediaSubscriptionFromJson(Map<String, dynamic> json) => MediaSubscription(
key: json['key'] as String? ?? '',
type: flexibleInt(json['type']),
provider: json['provider'] as String?,
targetLibrarySectionID: flexibleInt(json['targetLibrarySectionID']),
targetSectionLocationID: flexibleInt(json['targetSectionLocationID']),
title: json['title'] as String?,
selected: flexibleBoolNullable(json['selected']),
parameters: json['parameters'] as String?,
createdAt: flexibleInt(json['createdAt']),
storageTotal: flexibleInt(json['storageTotal']),
durationTotal: flexibleInt(json['durationTotal']),
airingsType: json['airingsType'] as String?,
librarySectionTitle: json['librarySectionTitle'] as String?,
locationPath: json['locationPath'] as String?,
video: _mapFromJson(json['Video']),
directory: _mapFromJson(json['Directory']),
playlist: _mapFromJson(json['Playlist']),
settings: json['Setting'] == null ? const [] : _parseSettings(json['Setting']),
grabOperations: json['MediaGrabOperation'] == null ? const [] : _parseGrabOperations(json['MediaGrabOperation']),
);
MediaSubscription _$MediaSubscriptionFromJson(Map<String, dynamic> json) =>
MediaSubscription(
key: json['key'] as String? ?? '',
type: flexibleInt(json['type']),
provider: json['provider'] as String?,
targetLibrarySectionID: flexibleInt(json['targetLibrarySectionID']),
targetSectionLocationID: flexibleInt(json['targetSectionLocationID']),
title: json['title'] as String?,
selected: flexibleBoolNullable(json['selected']),
parameters: json['parameters'] as String?,
createdAt: flexibleInt(json['createdAt']),
storageTotal: flexibleInt(json['storageTotal']),
durationTotal: flexibleInt(json['durationTotal']),
airingsType: json['airingsType'] as String?,
librarySectionTitle: json['librarySectionTitle'] as String?,
locationPath: json['locationPath'] as String?,
video: _mapFromJson(json['Video']),
directory: _mapFromJson(json['Directory']),
playlist: _mapFromJson(json['Playlist']),
settings: json['Setting'] == null
? const []
: _parseSettings(json['Setting']),
grabOperations: json['MediaGrabOperation'] == null
? const []
: _parseGrabOperations(json['MediaGrabOperation']),
);
SubscriptionSetting _$SubscriptionSettingFromJson(Map<String, dynamic> json) => SubscriptionSetting(
id: json['id'] as String? ?? '',
label: json['label'] as String?,
summary: json['summary'] as String?,
type: json['type'] as String?,
defaultValue: json['default'],
value: json['value'],
hidden: json['hidden'] == null ? false : flexibleBool(json['hidden']),
advanced: json['advanced'] == null ? false : flexibleBool(json['advanced']),
group: json['group'] as String?,
enumValues: json['enumValues'] as String?,
);
SubscriptionSetting _$SubscriptionSettingFromJson(Map<String, dynamic> json) =>
SubscriptionSetting(
id: json['id'] as String? ?? '',
label: json['label'] as String?,
summary: json['summary'] as String?,
type: json['type'] as String?,
defaultValue: json['default'],
value: json['value'],
hidden: json['hidden'] == null ? false : flexibleBool(json['hidden']),
advanced: json['advanced'] == null
? false
: flexibleBool(json['advanced']),
group: json['group'] as String?,
enumValues: json['enumValues'] as String?,
);
+5 -1
View File
@@ -13,7 +13,11 @@ PlexHome _$PlexHomeFromJson(Map<String, dynamic> json) => PlexHome(
guestUserUUID: json['guestUserUUID'] as String? ?? '',
guestEnabled: json['guestEnabled'] as bool? ?? false,
subscription: json['subscription'] as bool? ?? false,
users: (json['users'] as List<dynamic>?)?.map((e) => PlexHomeUser.fromJson(e as Map<String, dynamic>)).toList() ?? [],
users:
(json['users'] as List<dynamic>?)
?.map((e) => PlexHomeUser.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
Map<String, dynamic> _$PlexHomeToJson(PlexHome instance) => <String, dynamic>{
+16 -15
View File
@@ -22,18 +22,19 @@ PlexHomeUser _$PlexHomeUserFromJson(Map<String, dynamic> json) => PlexHomeUser(
protected: json['protected'] as bool? ?? false,
);
Map<String, dynamic> _$PlexHomeUserToJson(PlexHomeUser instance) => <String, dynamic>{
'id': instance.id,
'uuid': instance.uuid,
'title': instance.title,
'username': instance.username,
'email': instance.email,
'friendlyName': instance.friendlyName,
'thumb': instance.thumb,
'hasPassword': instance.hasPassword,
'restricted': instance.restricted,
'updatedAt': instance.updatedAt,
'admin': instance.admin,
'guest': instance.guest,
'protected': instance.protected,
};
Map<String, dynamic> _$PlexHomeUserToJson(PlexHomeUser instance) =>
<String, dynamic>{
'id': instance.id,
'uuid': instance.uuid,
'title': instance.title,
'username': instance.username,
'email': instance.email,
'friendlyName': instance.friendlyName,
'thumb': instance.thumb,
'hasPassword': instance.hasPassword,
'restricted': instance.restricted,
'updatedAt': instance.updatedAt,
'admin': instance.admin,
'guest': instance.guest,
'protected': instance.protected,
};
+22 -20
View File
@@ -6,24 +6,26 @@ part of 'plex_match_result.dart';
// JsonSerializableGenerator
// **************************************************************************
PlexMatchResult _$PlexMatchResultFromJson(Map<String, dynamic> json) => PlexMatchResult(
guid: readStringField(json, 'guid') as String? ?? '',
name: readStringField(json, 'name') as String? ?? '',
year: flexibleInt(json['year']),
score: flexibleInt(json['score']),
thumb: readStringField(json, 'thumb') as String?,
summary: readStringField(json, 'summary') as String?,
type: readStringField(json, 'type') as String?,
matched: json['matched'] == null ? false : flexibleBool(json['matched']),
);
PlexMatchResult _$PlexMatchResultFromJson(Map<String, dynamic> json) =>
PlexMatchResult(
guid: readStringField(json, 'guid') as String? ?? '',
name: readStringField(json, 'name') as String? ?? '',
year: flexibleInt(json['year']),
score: flexibleInt(json['score']),
thumb: readStringField(json, 'thumb') as String?,
summary: readStringField(json, 'summary') as String?,
type: readStringField(json, 'type') as String?,
matched: json['matched'] == null ? false : flexibleBool(json['matched']),
);
Map<String, dynamic> _$PlexMatchResultToJson(PlexMatchResult instance) => <String, dynamic>{
'guid': instance.guid,
'name': instance.name,
'year': instance.year,
'score': instance.score,
'thumb': instance.thumb,
'summary': instance.summary,
'type': instance.type,
'matched': instance.matched,
};
Map<String, dynamic> _$PlexMatchResultToJson(PlexMatchResult instance) =>
<String, dynamic>{
'guid': instance.guid,
'name': instance.name,
'year': instance.year,
'score': instance.score,
'thumb': instance.thumb,
'summary': instance.summary,
'type': instance.type,
'matched': instance.matched,
};
@@ -6,7 +6,9 @@ part of 'plex_subtitle_search_result.dart';
// JsonSerializableGenerator
// **************************************************************************
PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson(Map<String, dynamic> json) => PlexSubtitleSearchResult(
PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson(
Map<String, dynamic> json,
) => PlexSubtitleSearchResult(
id: _flexibleIntOrZero(json['id']),
key: readStringField(json, 'key') as String? ?? '',
codec: readStringField(json, 'codec') as String?,
@@ -16,13 +18,21 @@ PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson(Map<String, dynamic>
providerTitle: readStringField(json, 'providerTitle') as String?,
title: readStringField(json, 'title') as String?,
displayTitle: readStringField(json, 'displayTitle') as String?,
hearingImpaired: json['hearingImpaired'] == null ? false : flexibleBool(json['hearingImpaired']),
perfectMatch: json['perfectMatch'] == null ? false : flexibleBool(json['perfectMatch']),
downloaded: json['downloaded'] == null ? false : flexibleBool(json['downloaded']),
hearingImpaired: json['hearingImpaired'] == null
? false
: flexibleBool(json['hearingImpaired']),
perfectMatch: json['perfectMatch'] == null
? false
: flexibleBool(json['perfectMatch']),
downloaded: json['downloaded'] == null
? false
: flexibleBool(json['downloaded']),
forced: json['forced'] == null ? false : flexibleBool(json['forced']),
);
Map<String, dynamic> _$PlexSubtitleSearchResultToJson(PlexSubtitleSearchResult instance) => <String, dynamic>{
Map<String, dynamic> _$PlexSubtitleSearchResultToJson(
PlexSubtitleSearchResult instance,
) => <String, dynamic>{
'id': instance.id,
'key': instance.key,
'codec': instance.codec,
+33 -21
View File
@@ -6,32 +6,44 @@ part of 'plex_user_profile.dart';
// JsonSerializableGenerator
// **************************************************************************
PlexUserProfile _$PlexUserProfileFromJson(Map<String, dynamic> json) => PlexUserProfile(
PlexUserProfile _$PlexUserProfileFromJson(
Map<String, dynamic> json,
) => PlexUserProfile(
autoSelectAudio: json['autoSelectAudio'] as bool? ?? true,
defaultAudioAccessibility: (json['defaultAudioAccessibility'] as num?)?.toInt() ?? 0,
defaultAudioAccessibility:
(json['defaultAudioAccessibility'] as num?)?.toInt() ?? 0,
defaultAudioLanguage: json['defaultAudioLanguage'] as String?,
defaultAudioLanguages: (json['defaultAudioLanguages'] as List<dynamic>?)?.map((e) => e as String).toList(),
defaultAudioLanguages: (json['defaultAudioLanguages'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
defaultSubtitleLanguage: json['defaultSubtitleLanguage'] as String?,
defaultSubtitleLanguages: (json['defaultSubtitleLanguages'] as List<dynamic>?)?.map((e) => e as String).toList(),
defaultSubtitleLanguages: (json['defaultSubtitleLanguages'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
autoSelectSubtitle: (json['autoSelectSubtitle'] as num?)?.toInt() ?? 0,
defaultSubtitleAccessibility: (json['defaultSubtitleAccessibility'] as num?)?.toInt() ?? 0,
defaultSubtitleAccessibility:
(json['defaultSubtitleAccessibility'] as num?)?.toInt() ?? 0,
defaultSubtitleForced: (json['defaultSubtitleForced'] as num?)?.toInt() ?? 1,
watchedIndicator: (json['watchedIndicator'] as num?)?.toInt() ?? 1,
mediaReviewsVisibility: (json['mediaReviewsVisibility'] as num?)?.toInt() ?? 0,
mediaReviewsLanguages: (json['mediaReviewsLanguages'] as List<dynamic>?)?.map((e) => e as String).toList(),
mediaReviewsVisibility:
(json['mediaReviewsVisibility'] as num?)?.toInt() ?? 0,
mediaReviewsLanguages: (json['mediaReviewsLanguages'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
Map<String, dynamic> _$PlexUserProfileToJson(PlexUserProfile instance) => <String, dynamic>{
'autoSelectAudio': instance.autoSelectAudio,
'defaultAudioAccessibility': instance.defaultAudioAccessibility,
'defaultAudioLanguage': instance.defaultAudioLanguage,
'defaultAudioLanguages': instance.defaultAudioLanguages,
'defaultSubtitleLanguage': instance.defaultSubtitleLanguage,
'defaultSubtitleLanguages': instance.defaultSubtitleLanguages,
'autoSelectSubtitle': instance.autoSelectSubtitle,
'defaultSubtitleAccessibility': instance.defaultSubtitleAccessibility,
'defaultSubtitleForced': instance.defaultSubtitleForced,
'watchedIndicator': instance.watchedIndicator,
'mediaReviewsVisibility': instance.mediaReviewsVisibility,
'mediaReviewsLanguages': instance.mediaReviewsLanguages,
};
Map<String, dynamic> _$PlexUserProfileToJson(PlexUserProfile instance) =>
<String, dynamic>{
'autoSelectAudio': instance.autoSelectAudio,
'defaultAudioAccessibility': instance.defaultAudioAccessibility,
'defaultAudioLanguage': instance.defaultAudioLanguage,
'defaultAudioLanguages': instance.defaultAudioLanguages,
'defaultSubtitleLanguage': instance.defaultSubtitleLanguage,
'defaultSubtitleLanguages': instance.defaultSubtitleLanguages,
'autoSelectSubtitle': instance.autoSelectSubtitle,
'defaultSubtitleAccessibility': instance.defaultSubtitleAccessibility,
'defaultSubtitleForced': instance.defaultSubtitleForced,
'watchedIndicator': instance.watchedIndicator,
'mediaReviewsVisibility': instance.mediaReviewsVisibility,
'mediaReviewsLanguages': instance.mediaReviewsLanguages,
};
+4 -1
View File
@@ -1019,7 +1019,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
try {
final fullMetadata = await client.fetchItem(metadata.id);
if (fullMetadata != null) {
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName);
metadataToStore = fullMetadata.copyWith(
serverId: metadata.serverId ?? fullMetadata.serverId,
serverName: metadata.serverName ?? fullMetadata.serverName,
);
}
} catch (e) {
appLogger.w('Failed to fetch full metadata for ${metadata.id}, using partial', error: e);
@@ -50,10 +50,15 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti
static MediaItem applyPatch(MediaItem item, WatchStateOverlayPatch? patch) {
if (patch == null) return item;
return item.copyWith(
viewCount: patch.isWatched == null ? null : (patch.isWatched! ? 1 : 0),
viewOffsetMs: patch.hasViewOffsetMs ? patch.viewOffsetMs : null,
);
var updated = item;
final isWatched = patch.isWatched;
if (isWatched != null) {
updated = updated.copyWith(viewCount: isWatched ? 1 : 0);
}
if (patch.hasViewOffsetMs) {
updated = updated.copyWith(viewOffsetMs: patch.viewOffsetMs);
}
return updated;
}
void setActiveProfileId(String? profileId) {
+43 -16
View File
@@ -229,19 +229,24 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final isWatched = event.isNowWatched;
if (isWatched == null) return;
final viewOffsetMs = isWatched && !clearWatchedProgress ? null : 0;
MediaItem patchItem(MediaItem item) {
final updated = item.copyWith(viewCount: isWatched ? 1 : 0);
return viewOffsetMs == null ? updated : updated.copyWith(viewOffsetMs: viewOffsetMs);
}
setStateIfMounted(() {
final base = _fullMetadata ?? widget.metadata;
if (base.id == event.itemId) {
_fullMetadata = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
_fullMetadata = patchItem(base);
}
final onDeckEpisode = _onDeckEpisode;
if (onDeckEpisode != null && onDeckEpisode.id == event.itemId) {
_onDeckEpisode = onDeckEpisode.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
_onDeckEpisode = patchItem(onDeckEpisode);
}
if (epIndex != -1) {
final updated = _episodes[epIndex].copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
final updated = patchItem(_episodes[epIndex]);
_episodes[epIndex] = updated;
_syncEpisodeToCache(epIndex, updated);
}
@@ -272,7 +277,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
MediaItem _applyLocalProgress(MediaItem item) {
if (!_localProgressById.containsKey(item.id)) return item;
return item.copyWith(viewOffsetMs: _localProgressById[item.id]);
return item.copyWith(viewOffsetMs: _localProgressById[item.id]!);
}
@override
@@ -395,10 +400,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final onDeckEpisode = result.onDeckEpisode;
if (metadata != null) {
setStateIfMounted(() {
_fullMetadata = _applyLocalProgress(metadata.copyWith(serverId: serverId, serverName: serverName));
_fullMetadata = _applyLocalProgress(
metadata.copyWith(serverId: serverId, serverName: serverName ?? metadata.serverName),
);
_onDeckEpisode = onDeckEpisode == null
? null
: _applyLocalProgress(onDeckEpisode.copyWith(serverId: serverId, serverName: serverName));
: _applyLocalProgress(
onDeckEpisode.copyWith(serverId: serverId, serverName: serverName ?? onDeckEpisode.serverName),
);
});
}
@@ -406,7 +415,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final seasons = await mediaClient.fetchChildren(_metadata.id);
_episodeCache.clear();
setStateIfMounted(() {
_seasons = seasons.map((s) => s.copyWith(serverId: serverId, serverName: serverName)).toList();
_seasons = seasons
.map((s) => s.copyWith(serverId: serverId, serverName: serverName ?? s.serverName))
.toList();
});
if (_showEpisodesDirectly) {
await _fetchAllEpisodes();
@@ -992,10 +1003,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Preserve serverId from original metadata
final serverId = _metadata.serverId;
final serverName = _metadata.serverName;
final base = _applyLocalProgress((metadata ?? _metadata).copyWith(serverId: serverId, serverName: serverName));
final source = metadata ?? _metadata;
final base = _applyLocalProgress(
source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName),
);
final onDeckWithServerId = onDeckEpisode == null
? null
: _applyLocalProgress(onDeckEpisode.copyWith(serverId: serverId, serverName: serverName));
: _applyLocalProgress(
onDeckEpisode.copyWith(
serverId: serverId ?? onDeckEpisode.serverId,
serverName: serverName ?? onDeckEpisode.serverName,
),
);
setState(() {
_fullMetadata = base;
@@ -1065,7 +1084,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Preserve serverId for each season.
final seasonsWithServerId = seasons
.map((season) => season.copyWith(serverId: serverId, serverName: _metadata.serverName))
.map((season) => season.copyWith(serverId: serverId, serverName: _metadata.serverName ?? season.serverName))
.toList();
// Plex's flattenSeasons modes: 1 = always, 2 = single-season only.
@@ -1257,10 +1276,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final episodesWithServerId = episodes
.map(
(e) => e.copyWith(
serverId: _metadata.serverId,
serverName: _metadata.serverName,
serverId: _metadata.serverId ?? e.serverId,
serverName: _metadata.serverName ?? e.serverName,
grandparentId: _metadata.id,
grandparentTitle: _metadata.title,
grandparentTitle: _metadata.title ?? e.grandparentTitle,
),
)
.map(_applyLocalProgress)
@@ -1301,7 +1320,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Preserve serverId for each extra (needed for multi-server setups).
final extrasWithServerId = extras
.map((extra) => extra.copyWith(serverId: _metadata.serverId, serverName: _metadata.serverName))
.map(
(extra) => extra.copyWith(
serverId: _metadata.serverId ?? extra.serverId,
serverName: _metadata.serverName ?? extra.serverName,
),
)
.toList();
setStateIfMounted(() {
@@ -1985,7 +2009,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
.map(
(e) => e.copyWith(
serverId: serverId,
serverName: _metadata.serverName,
serverName: _metadata.serverName ?? e.serverName,
grandparentId: e.grandparentId ?? fallbackGrandparentId,
grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle,
),
@@ -2070,7 +2094,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Play the first episode
final firstEpisode = episodes.first;
// Preserve serverId for the episode
final episodeWithServerId = firstEpisode.copyWith(serverId: _metadata.serverId, serverName: _metadata.serverName);
final episodeWithServerId = firstEpisode.copyWith(
serverId: _metadata.serverId ?? firstEpisode.serverId,
serverName: _metadata.serverName ?? firstEpisode.serverName,
);
if (mounted) {
appLogger.d('Playing first episode: ${episodeWithServerId.title}');
await navigateToVideoPlayerWithRefresh(
@@ -162,7 +162,9 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
if (raw == null || raw.isEmpty) return const PlayQueueEmpty();
final shuffled = List.of(raw)..shuffle(Random());
final items = shuffled.map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName)).toList();
final items = shuffled
.map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName ?? e.serverName))
.toList();
await dismissLoading();
if (!context.mounted && navigateForTesting == null) {
+1 -1
View File
@@ -286,7 +286,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
var itemToPlay = selectedItem ?? playQueue.items!.first;
if (copyServerInfo && serverId != null) {
itemToPlay = itemToPlay.copyWith(serverId: serverId, serverName: serverName);
itemToPlay = itemToPlay.copyWith(serverId: serverId, serverName: serverName ?? itemToPlay.serverName);
}
await navigateToVideoPlayer(context, metadata: itemToPlay);
+172 -164
View File
@@ -16,51 +16,54 @@ PlexRoleDto _$PlexRoleDtoFromJson(Map<String, dynamic> json) => PlexRoleDto(
count: flexibleInt(json['count']),
);
PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map<String, dynamic> json) => PlexMediaVersionDto(
id: _flexibleIntOrZero(json['id']),
videoResolution: readStringField(json, 'videoResolution') as String?,
videoCodec: readStringField(json, 'videoCodec') as String?,
bitrate: flexibleInt(json['bitrate']),
width: flexibleInt(json['width']),
height: flexibleInt(json['height']),
container: readStringField(json, 'container') as String?,
partKey: _readPartKey(json, 'partKey') as String,
accessible: _readPartAccessible(json, 'accessible') as bool?,
exists: _readPartExists(json, 'exists') as bool?,
);
PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map<String, dynamic> json) =>
PlexMediaVersionDto(
id: _flexibleIntOrZero(json['id']),
videoResolution: readStringField(json, 'videoResolution') as String?,
videoCodec: readStringField(json, 'videoCodec') as String?,
bitrate: flexibleInt(json['bitrate']),
width: flexibleInt(json['width']),
height: flexibleInt(json['height']),
container: readStringField(json, 'container') as String?,
partKey: _readPartKey(json, 'partKey') as String,
accessible: _readPartAccessible(json, 'accessible') as bool?,
exists: _readPartExists(json, 'exists') as bool?,
);
PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> json) => PlexLibraryDto(
key: readStringField(json, 'key') as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String? ?? '',
agent: json['agent'] as String?,
scanner: json['scanner'] as String?,
language: json['language'] as String?,
uuid: json['uuid'] as String?,
updatedAt: flexibleInt(json['updatedAt']),
createdAt: flexibleInt(json['createdAt']),
hidden: flexibleInt(json['hidden']),
);
PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> json) =>
PlexLibraryDto(
key: readStringField(json, 'key') as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String? ?? '',
agent: json['agent'] as String?,
scanner: json['scanner'] as String?,
language: json['language'] as String?,
uuid: json['uuid'] as String?,
updatedAt: flexibleInt(json['updatedAt']),
createdAt: flexibleInt(json['createdAt']),
hidden: flexibleInt(json['hidden']),
);
PlexPlaylistDto _$PlexPlaylistDtoFromJson(Map<String, dynamic> json) => PlexPlaylistDto(
ratingKey: readStringField(json, 'ratingKey') as String? ?? '',
key: json['key'] as String? ?? '',
type: json['type'] as String? ?? '',
title: json['title'] as String? ?? '',
summary: json['summary'] as String?,
smart: json['smart'] as bool? ?? false,
playlistType: json['playlistType'] as String? ?? '',
duration: flexibleInt(json['duration']),
leafCount: flexibleInt(json['leafCount']),
composite: json['composite'] as String?,
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
viewCount: flexibleInt(json['viewCount']),
content: json['content'] as String?,
guid: json['guid'] as String?,
thumb: json['thumb'] as String?,
);
PlexPlaylistDto _$PlexPlaylistDtoFromJson(Map<String, dynamic> json) =>
PlexPlaylistDto(
ratingKey: readStringField(json, 'ratingKey') as String? ?? '',
key: json['key'] as String? ?? '',
type: json['type'] as String? ?? '',
title: json['title'] as String? ?? '',
summary: json['summary'] as String?,
smart: json['smart'] as bool? ?? false,
playlistType: json['playlistType'] as String? ?? '',
duration: flexibleInt(json['duration']),
leafCount: flexibleInt(json['leafCount']),
composite: json['composite'] as String?,
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
viewCount: flexibleInt(json['viewCount']),
content: json['content'] as String?,
guid: json['guid'] as String?,
thumb: json['thumb'] as String?,
);
PlexHubDto _$PlexHubDtoFromJson(Map<String, dynamic> json) => PlexHubDto(
hubKey: readStringField(json, 'key') as String? ?? '',
@@ -72,125 +75,130 @@ PlexHubDto _$PlexHubDtoFromJson(Map<String, dynamic> json) => PlexHubDto(
items: _hubItemsFromJson(_readHubItems(json, 'items')),
);
PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) => PlexMetadataDto(
ratingKey: _readMetadataRatingKey(json, 'ratingKey') as String? ?? '',
key: json['key'] as String?,
guid: json['guid'] as String?,
studio: json['studio'] as String?,
type: json['type'] as String?,
title: json['title'] as String?,
titleSort: json['titleSort'] as String?,
contentRating: json['contentRating'] as String?,
summary: json['summary'] as String?,
rating: (json['rating'] as num?)?.toDouble(),
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
userRating: (json['userRating'] as num?)?.toDouble(),
year: flexibleInt(json['year']),
originallyAvailableAt: json['originallyAvailableAt'] as String?,
thumb: json['thumb'] as String?,
art: json['art'] as String?,
duration: flexibleInt(json['duration']),
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumb: json['grandparentThumb'] as String?,
grandparentArt: json['grandparentArt'] as String?,
grandparentRatingKey: readStringField(json, 'grandparentRatingKey') as String?,
parentTitle: json['parentTitle'] as String?,
parentThumb: json['parentThumb'] as String?,
parentRatingKey: readStringField(json, 'parentRatingKey') as String?,
parentIndex: flexibleInt(json['parentIndex']),
index: flexibleInt(json['index']),
grandparentTheme: json['grandparentTheme'] as String?,
viewOffset: flexibleInt(json['viewOffset']),
viewCount: flexibleInt(json['viewCount']),
leafCount: flexibleInt(json['leafCount']),
viewedLeafCount: flexibleInt(json['viewedLeafCount']),
childCount: flexibleInt(json['childCount']),
role: (json['Role'] as List<dynamic>?)?.map((e) => PlexRoleDto.fromJson(e as Map<String, dynamic>)).toList(),
mediaVersions: (json['Media'] as List<dynamic>?)
?.map((e) => PlexMediaVersionDto.fromJson(e as Map<String, dynamic>))
.toList(),
genre: _tagListFromJson(json['Genre']),
director: _tagListFromJson(json['Director']),
writer: _tagListFromJson(json['Writer']),
producer: _tagListFromJson(json['Producer']),
country: _tagListFromJson(json['Country']),
collection: _tagListFromJson(json['Collection']),
label: _tagListFromJson(json['Label']),
style: _tagListFromJson(json['Style']),
mood: _tagListFromJson(json['Mood']),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: flexibleInt(json['subtitleMode']),
playlistItemID: flexibleInt(json['playlistItemID']),
playQueueItemID: flexibleInt(json['playQueueItemID']),
librarySectionID: flexibleInt(json['librarySectionID']),
librarySectionTitle: json['librarySectionTitle'] as String?,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
tagline: json['tagline'] as String?,
originalTitle: json['originalTitle'] as String?,
editionTitle: json['editionTitle'] as String?,
subtype: json['subtype'] as String?,
extraType: flexibleInt(json['extraType']),
primaryExtraKey: json['primaryExtraKey'] as String?,
clearLogo: json['clearLogo'] as String?,
backgroundSquare: json['backgroundSquare'] as String?,
);
PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
PlexMetadataDto(
ratingKey: _readMetadataRatingKey(json, 'ratingKey') as String? ?? '',
key: json['key'] as String?,
guid: json['guid'] as String?,
studio: json['studio'] as String?,
type: json['type'] as String?,
title: json['title'] as String?,
titleSort: json['titleSort'] as String?,
contentRating: json['contentRating'] as String?,
summary: json['summary'] as String?,
rating: (json['rating'] as num?)?.toDouble(),
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
userRating: (json['userRating'] as num?)?.toDouble(),
year: flexibleInt(json['year']),
originallyAvailableAt: json['originallyAvailableAt'] as String?,
thumb: json['thumb'] as String?,
art: json['art'] as String?,
duration: flexibleInt(json['duration']),
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumb: json['grandparentThumb'] as String?,
grandparentArt: json['grandparentArt'] as String?,
grandparentRatingKey:
readStringField(json, 'grandparentRatingKey') as String?,
parentTitle: json['parentTitle'] as String?,
parentThumb: json['parentThumb'] as String?,
parentRatingKey: readStringField(json, 'parentRatingKey') as String?,
parentIndex: flexibleInt(json['parentIndex']),
index: flexibleInt(json['index']),
grandparentTheme: json['grandparentTheme'] as String?,
viewOffset: flexibleInt(json['viewOffset']),
viewCount: flexibleInt(json['viewCount']),
leafCount: flexibleInt(json['leafCount']),
viewedLeafCount: flexibleInt(json['viewedLeafCount']),
childCount: flexibleInt(json['childCount']),
role: (json['Role'] as List<dynamic>?)
?.map((e) => PlexRoleDto.fromJson(e as Map<String, dynamic>))
.toList(),
mediaVersions: (json['Media'] as List<dynamic>?)
?.map((e) => PlexMediaVersionDto.fromJson(e as Map<String, dynamic>))
.toList(),
genre: _tagListFromJson(json['Genre']),
director: _tagListFromJson(json['Director']),
writer: _tagListFromJson(json['Writer']),
producer: _tagListFromJson(json['Producer']),
country: _tagListFromJson(json['Country']),
collection: _tagListFromJson(json['Collection']),
label: _tagListFromJson(json['Label']),
style: _tagListFromJson(json['Style']),
mood: _tagListFromJson(json['Mood']),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: flexibleInt(json['subtitleMode']),
playlistItemID: flexibleInt(json['playlistItemID']),
playQueueItemID: flexibleInt(json['playQueueItemID']),
librarySectionID: flexibleInt(json['librarySectionID']),
librarySectionTitle: json['librarySectionTitle'] as String?,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
tagline: json['tagline'] as String?,
originalTitle: json['originalTitle'] as String?,
editionTitle: json['editionTitle'] as String?,
subtype: json['subtype'] as String?,
extraType: flexibleInt(json['extraType']),
primaryExtraKey: json['primaryExtraKey'] as String?,
clearLogo: json['clearLogo'] as String?,
backgroundSquare: json['backgroundSquare'] as String?,
);
Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) => <String, dynamic>{
'ratingKey': instance.ratingKey,
'key': ?instance.key,
'guid': ?instance.guid,
'studio': ?instance.studio,
'type': ?instance.type,
'title': ?instance.title,
'titleSort': ?instance.titleSort,
'contentRating': ?instance.contentRating,
'summary': ?instance.summary,
'rating': ?instance.rating,
'audienceRating': ?instance.audienceRating,
'userRating': ?instance.userRating,
'year': ?instance.year,
'originallyAvailableAt': ?instance.originallyAvailableAt,
'thumb': ?instance.thumb,
'art': ?instance.art,
'duration': ?instance.duration,
'addedAt': ?instance.addedAt,
'updatedAt': ?instance.updatedAt,
'lastViewedAt': ?instance.lastViewedAt,
'grandparentTitle': ?instance.grandparentTitle,
'grandparentThumb': ?instance.grandparentThumb,
'grandparentArt': ?instance.grandparentArt,
'grandparentRatingKey': ?instance.grandparentRatingKey,
'parentTitle': ?instance.parentTitle,
'parentThumb': ?instance.parentThumb,
'parentRatingKey': ?instance.parentRatingKey,
'parentIndex': ?instance.parentIndex,
'index': ?instance.index,
'grandparentTheme': ?instance.grandparentTheme,
'viewOffset': ?instance.viewOffset,
'viewCount': ?instance.viewCount,
'leafCount': ?instance.leafCount,
'viewedLeafCount': ?instance.viewedLeafCount,
'childCount': ?instance.childCount,
'audioLanguage': ?instance.audioLanguage,
'subtitleLanguage': ?instance.subtitleLanguage,
'subtitleMode': ?instance.subtitleMode,
'playlistItemID': ?instance.playlistItemID,
'playQueueItemID': ?instance.playQueueItemID,
'librarySectionID': ?instance.librarySectionID,
'librarySectionTitle': ?instance.librarySectionTitle,
'ratingImage': ?instance.ratingImage,
'audienceRatingImage': ?instance.audienceRatingImage,
'tagline': ?instance.tagline,
'originalTitle': ?instance.originalTitle,
'editionTitle': ?instance.editionTitle,
'subtype': ?instance.subtype,
'extraType': ?instance.extraType,
'primaryExtraKey': ?instance.primaryExtraKey,
'clearLogo': ?instance.clearLogo,
'backgroundSquare': ?instance.backgroundSquare,
};
Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
<String, dynamic>{
'ratingKey': instance.ratingKey,
'key': ?instance.key,
'guid': ?instance.guid,
'studio': ?instance.studio,
'type': ?instance.type,
'title': ?instance.title,
'titleSort': ?instance.titleSort,
'contentRating': ?instance.contentRating,
'summary': ?instance.summary,
'rating': ?instance.rating,
'audienceRating': ?instance.audienceRating,
'userRating': ?instance.userRating,
'year': ?instance.year,
'originallyAvailableAt': ?instance.originallyAvailableAt,
'thumb': ?instance.thumb,
'art': ?instance.art,
'duration': ?instance.duration,
'addedAt': ?instance.addedAt,
'updatedAt': ?instance.updatedAt,
'lastViewedAt': ?instance.lastViewedAt,
'grandparentTitle': ?instance.grandparentTitle,
'grandparentThumb': ?instance.grandparentThumb,
'grandparentArt': ?instance.grandparentArt,
'grandparentRatingKey': ?instance.grandparentRatingKey,
'parentTitle': ?instance.parentTitle,
'parentThumb': ?instance.parentThumb,
'parentRatingKey': ?instance.parentRatingKey,
'parentIndex': ?instance.parentIndex,
'index': ?instance.index,
'grandparentTheme': ?instance.grandparentTheme,
'viewOffset': ?instance.viewOffset,
'viewCount': ?instance.viewCount,
'leafCount': ?instance.leafCount,
'viewedLeafCount': ?instance.viewedLeafCount,
'childCount': ?instance.childCount,
'audioLanguage': ?instance.audioLanguage,
'subtitleLanguage': ?instance.subtitleLanguage,
'subtitleMode': ?instance.subtitleMode,
'playlistItemID': ?instance.playlistItemID,
'playQueueItemID': ?instance.playQueueItemID,
'librarySectionID': ?instance.librarySectionID,
'librarySectionTitle': ?instance.librarySectionTitle,
'ratingImage': ?instance.ratingImage,
'audienceRatingImage': ?instance.audienceRatingImage,
'tagline': ?instance.tagline,
'originalTitle': ?instance.originalTitle,
'editionTitle': ?instance.editionTitle,
'subtype': ?instance.subtype,
'extraType': ?instance.extraType,
'primaryExtraKey': ?instance.primaryExtraKey,
'clearLogo': ?instance.clearLogo,
'backgroundSquare': ?instance.backgroundSquare,
};
@@ -6,16 +6,18 @@ part of 'anilist_session.dart';
// JsonSerializableGenerator
// **************************************************************************
AnilistSession _$AnilistSessionFromJson(Map<String, dynamic> json) => AnilistSession(
accessToken: json['access_token'] as String,
expiresAt: (json['expires_at'] as num).toInt(),
createdAt: (json['created_at'] as num).toInt(),
username: json['username'] as String?,
);
AnilistSession _$AnilistSessionFromJson(Map<String, dynamic> json) =>
AnilistSession(
accessToken: json['access_token'] as String,
expiresAt: (json['expires_at'] as num).toInt(),
createdAt: (json['created_at'] as num).toInt(),
username: json['username'] as String?,
);
Map<String, dynamic> _$AnilistSessionToJson(AnilistSession instance) => <String, dynamic>{
'access_token': instance.accessToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'created_at': instance.createdAt,
};
Map<String, dynamic> _$AnilistSessionToJson(AnilistSession instance) =>
<String, dynamic>{
'access_token': instance.accessToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'created_at': instance.createdAt,
};
+8 -7
View File
@@ -14,10 +14,11 @@ MalSession _$MalSessionFromJson(Map<String, dynamic> json) => MalSession(
username: json['username'] as String?,
);
Map<String, dynamic> _$MalSessionToJson(MalSession instance) => <String, dynamic>{
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'created_at': instance.createdAt,
};
Map<String, dynamic> _$MalSessionToJson(MalSession instance) =>
<String, dynamic>{
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'created_at': instance.createdAt,
};
@@ -12,8 +12,9 @@ SimklSession _$SimklSessionFromJson(Map<String, dynamic> json) => SimklSession(
username: json['username'] as String?,
);
Map<String, dynamic> _$SimklSessionToJson(SimklSession instance) => <String, dynamic>{
'access_token': instance.accessToken,
'username': instance.username,
'created_at': instance.createdAt,
};
Map<String, dynamic> _$SimklSessionToJson(SimklSession instance) =>
<String, dynamic>{
'access_token': instance.accessToken,
'username': instance.username,
'created_at': instance.createdAt,
};
+9 -8
View File
@@ -15,11 +15,12 @@ TraktSession _$TraktSessionFromJson(Map<String, dynamic> json) => TraktSession(
username: json['username'] as String?,
);
Map<String, dynamic> _$TraktSessionToJson(TraktSession instance) => <String, dynamic>{
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'scope': instance.scope,
'created_at': instance.createdAt,
};
Map<String, dynamic> _$TraktSessionToJson(TraktSession instance) =>
<String, dynamic>{
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'scope': instance.scope,
'created_at': instance.createdAt,
};
@@ -13,9 +13,10 @@ RecentRoom _$RecentRoomFromJson(Map<String, dynamic> json) => RecentRoom(
controlMode: _controlModeFromIndex((json['controlMode'] as num?)?.toInt()),
);
Map<String, dynamic> _$RecentRoomToJson(RecentRoom instance) => <String, dynamic>{
'code': instance.code,
'name': ?instance.name,
'lastUsed': _dateTimeToMillis(instance.lastUsed),
'controlMode': ?_controlModeToIndex(instance.controlMode),
};
Map<String, dynamic> _$RecentRoomToJson(RecentRoom instance) =>
<String, dynamic>{
'code': instance.code,
'name': ?instance.name,
'lastUsed': _dateTimeToMillis(instance.lastUsed),
'controlMode': ?_controlModeToIndex(instance.controlMode),
};
+16
View File
@@ -482,6 +482,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131
url: "https://pub.dev"
source: hosted
version: "3.2.5"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
glob:
dependency: transitive
description:
+2
View File
@@ -70,6 +70,7 @@ dependencies:
cronet_http: ^1.6.0
win_http: ^0.2.0
collection: ^1.18.0
freezed_annotation: ^3.1.0
dev_dependencies:
flutter_test:
@@ -83,6 +84,7 @@ dev_dependencies:
shared_preferences_platform_interface: ^2.4.0
path_provider_platform_interface: ^2.1.0
plugin_platform_interface: ^2.1.0
freezed: ^3.2.5
dependency_overrides:
auto_updater_platform_interface:
+134
View File
@@ -2,6 +2,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_role.dart';
import 'package:plezy/media/media_version.dart';
/// Backend-agnostic [MediaItem] tests. Existing coverage is split between
/// `plex_mappers_test` and `jellyfin_mappers_test` — those exercise the
@@ -187,6 +190,137 @@ void main() {
expect(original.copyWith(title: 'New').backend, backend, reason: 'copyWith must preserve backend');
}
});
test('preserves Plex-only fields when omitted', () {
const original = PlexMediaItem(
id: 'p1',
kind: MediaKind.movie,
title: 'Old',
editionTitle: 'Director Cut',
audienceRating: 8.9,
ratingImage: 'rottentomatoes://rating',
audienceRatingImage: 'rottentomatoes://audience',
subtitleLanguage: 'eng',
subtitleMode: 1,
trailerKey: '/library/metadata/1',
playlistItemId: 42,
playQueueItemId: 7,
subtype: 'trailer',
extraType: 1,
);
final copy = original.copyWith(title: 'New');
expect(copy.title, 'New');
expect(copy.editionTitle, 'Director Cut');
expect(copy.audienceRating, 8.9);
expect(copy.ratingImage, 'rottentomatoes://rating');
expect(copy.audienceRatingImage, 'rottentomatoes://audience');
expect(copy.subtitleLanguage, 'eng');
expect(copy.subtitleMode, 1);
expect(copy.trailerKey, '/library/metadata/1');
expect(copy.playlistItemId, 42);
expect(copy.playQueueItemId, 7);
expect(copy.subtype, 'trailer');
expect(copy.extraType, 1);
});
test('preserves Jellyfin playlist item id when omitted', () {
const original = JellyfinMediaItem(
id: 'j1',
kind: MediaKind.movie,
title: 'Old',
playlistItemId: 'playlist-entry-1',
);
final copy = original.copyWith(title: 'New');
expect(copy.title, 'New');
expect(copy.playlistItemId, 'playlist-entry-1');
});
test('can clear nullable fields explicitly', () {
final original = _movie(title: 'Movie', viewCount: 1, durationMs: 1000, viewOffsetMs: 500);
final copy = original.copyWith(title: null, viewOffsetMs: null);
expect(copy.title, isNull);
expect(copy.viewOffsetMs, isNull);
expect(copy.viewCount, 1);
});
});
group('MediaItem JSON', () {
test('round-trips Plex-only fields', () {
const original = PlexMediaItem(
id: 'p1',
kind: MediaKind.movie,
title: 'Movie',
editionTitle: 'Theatrical',
audienceRating: 9.1,
ratingImage: 'rottentomatoes://rating',
audienceRatingImage: 'rottentomatoes://audience',
genres: ['Drama'],
roles: [MediaRole(id: '1', tag: 'Actor', role: 'Lead', thumbPath: '/photo')],
mediaVersions: [
MediaVersion(
id: 'v1',
width: 1920,
height: 1080,
parts: [MediaPart(id: 'part1', streamPath: '/stream', sizeBytes: 1000)],
),
],
subtitleLanguage: 'eng',
subtitleMode: 2,
trailerKey: '/trailer',
playlistItemId: 4,
playQueueItemId: 5,
subtype: 'trailer',
extraType: 9,
);
final json = original.toJson();
final decoded = MediaItem.fromJson(json);
expect(json['backend'], 'plex');
expect(json.containsKey('summary'), isFalse);
expect(decoded, isA<PlexMediaItem>());
final plex = decoded as PlexMediaItem;
expect(plex.editionTitle, 'Theatrical');
expect(plex.audienceRating, 9.1);
expect(plex.ratingImage, 'rottentomatoes://rating');
expect(plex.audienceRatingImage, 'rottentomatoes://audience');
expect(plex.genres, ['Drama']);
expect(plex.roles?.single.tag, 'Actor');
expect(plex.mediaVersions?.single.parts.single.streamPath, '/stream');
expect(plex.subtitleLanguage, 'eng');
expect(plex.subtitleMode, 2);
expect(plex.trailerKey, '/trailer');
expect(plex.playlistItemId, 4);
expect(plex.playQueueItemId, 5);
expect(plex.subtype, 'trailer');
expect(plex.extraType, 9);
});
test('round-trips Jellyfin playlist item id', () {
const original = JellyfinMediaItem(id: 'j1', kind: MediaKind.movie, title: 'Movie', playlistItemId: 'entry-1');
final json = original.toJson();
final decoded = MediaItem.fromJson(json);
expect(json['backend'], 'jellyfin');
expect(decoded, isA<JellyfinMediaItem>());
expect((decoded as JellyfinMediaItem).playlistItemId, 'entry-1');
});
test('missing backend keeps legacy Plex fallback', () {
final decoded = MediaItem.fromJson({'id': 'legacy', 'kind': 'movie'});
expect(decoded, isA<PlexMediaItem>());
expect(decoded.backend, MediaBackend.plex);
expect(decoded.id, 'legacy');
expect(decoded.kind, MediaKind.movie);
});
});
group('MediaItem.displayTitle', () {