From fd1eaf9d967472a4b31a40f82103e2dcc3e83151 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 11 May 2026 12:55:09 +0200 Subject: [PATCH] fix(plex): respect per-show season display close #1008 --- lib/screens/media_detail_screen.dart | 17 +++---- lib/services/plex_mappers.dart | 20 +++++++- lib/services/plex_mappers.g.dart | 4 ++ lib/utils/plex_season_display.dart | 23 +++++++++ test/services/plex_mappers_test.dart | 17 +++++++ test/utils/plex_season_display_test.dart | 65 ++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 lib/utils/plex_season_display.dart create mode 100644 test/utils/plex_season_display_test.dart diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index b072b08d..105d7bc5 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -23,6 +23,7 @@ import '../exceptions/media_server_exceptions.dart'; import '../media/media_backend.dart'; import '../media/media_hub.dart'; import '../utils/provider_extensions.dart'; +import '../utils/plex_season_display.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; @@ -1083,17 +1084,15 @@ class _MediaDetailScreenState extends State .map((season) => season.copyWith(serverId: serverId, serverName: _metadata.serverName ?? season.serverName)) .toList(); - // Plex's flattenSeasons modes: 1 = always, 2 = single-season only. - // Jellyfin falls through to "flatten when there's a single season". + // Plex can override the library season mode per show; Jellyfin falls + // through to "flatten when there's a single season". bool shouldShowEpisodesDirectly; if (client is PlexClient) { - const flattenSeasonsAlways = 1; - const flattenSeasonsSingleSeason = 2; - final flattenSeasons = int.tryParse(prefs['flattenSeasons']?.toString() ?? ''); - final isAlways = flattenSeasons == flattenSeasonsAlways; - final isSingleSeason = flattenSeasons == flattenSeasonsSingleSeason; - shouldShowEpisodesDirectly = - isAlways || seasonsWithServerId.isEmpty || (isSingleSeason && seasonsWithServerId.length == 1); + shouldShowEpisodesDirectly = shouldShowPlexEpisodesDirectly( + show: _metadata, + seasons: seasonsWithServerId, + libraryPrefs: prefs, + ); } else { shouldShowEpisodesDirectly = seasonsWithServerId.length <= 1; } diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index dd51c3c9..8154224f 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -465,6 +465,10 @@ class PlexMetadataDto { final String? serverName; final String? clearLogo; final String? backgroundSquare; + @JsonKey(fromJson: flexibleBoolNullable) + final bool? skipChildren; + @JsonKey(fromJson: flexibleInt) + final int? flattenSeasons; const PlexMetadataDto({ required this.ratingKey, @@ -532,6 +536,8 @@ class PlexMetadataDto { this.serverName, this.clearLogo, this.backgroundSquare, + this.skipChildren, + this.flattenSeasons, }); factory PlexMetadataDto.fromJson(Map rawJson) { @@ -659,6 +665,8 @@ class PlexMetadataDto { String? serverName, String? clearLogo, String? backgroundSquare, + bool? skipChildren, + int? flattenSeasons, }) { return PlexMetadataDto( ratingKey: ratingKey ?? this.ratingKey, @@ -726,10 +734,20 @@ class PlexMetadataDto { serverName: serverName ?? this.serverName, clearLogo: clearLogo ?? this.clearLogo, backgroundSquare: backgroundSquare ?? this.backgroundSquare, + skipChildren: skipChildren ?? this.skipChildren, + flattenSeasons: flattenSeasons ?? this.flattenSeasons, ); } } +Map? _rawMetadata(PlexMetadataDto dto) { + final raw = {}; + if (dto.key != null) raw['key'] = dto.key; + if (dto.skipChildren != null) raw['skipChildren'] = dto.skipChildren; + if (dto.flattenSeasons != null) raw['flattenSeasons'] = dto.flattenSeasons; + return raw.isEmpty ? null : raw; +} + /// Pure JSON/DTO→neutral-type mappers for Plex. Mirrors [JellyfinMappers]. /// /// Methods come in two flavours: @@ -827,7 +845,7 @@ class PlexMappers { extraType: dto.extraType, serverId: dto.serverId, serverName: dto.serverName, - raw: dto.key != null ? {'key': dto.key} : null, + raw: _rawMetadata(dto), ); } diff --git a/lib/services/plex_mappers.g.dart b/lib/services/plex_mappers.g.dart index 21be3f4b..ca68723f 100644 --- a/lib/services/plex_mappers.g.dart +++ b/lib/services/plex_mappers.g.dart @@ -145,6 +145,8 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map json) => primaryExtraKey: json['primaryExtraKey'] as String?, clearLogo: json['clearLogo'] as String?, backgroundSquare: json['backgroundSquare'] as String?, + skipChildren: flexibleBoolNullable(json['skipChildren']), + flattenSeasons: flexibleInt(json['flattenSeasons']), ); Map _$PlexMetadataDtoToJson(PlexMetadataDto instance) => @@ -201,4 +203,6 @@ Map _$PlexMetadataDtoToJson(PlexMetadataDto instance) => 'primaryExtraKey': ?instance.primaryExtraKey, 'clearLogo': ?instance.clearLogo, 'backgroundSquare': ?instance.backgroundSquare, + 'skipChildren': ?instance.skipChildren, + 'flattenSeasons': ?instance.flattenSeasons, }; diff --git a/lib/utils/plex_season_display.dart b/lib/utils/plex_season_display.dart new file mode 100644 index 00000000..1194b9ed --- /dev/null +++ b/lib/utils/plex_season_display.dart @@ -0,0 +1,23 @@ +import '../media/media_item.dart'; +import 'json_utils.dart'; + +const _plexFlattenSeasonsShow = 0; +const _plexFlattenSeasonsHide = 1; +const _plexFlattenSeasonsSingleSeason = 2; + +bool shouldShowPlexEpisodesDirectly({ + required MediaItem show, + required List seasons, + required Map libraryPrefs, +}) { + final showOverride = flexibleInt(show.raw?['flattenSeasons']); + if (showOverride == _plexFlattenSeasonsHide) return true; + if (showOverride == _plexFlattenSeasonsShow) return false; + + if (flexibleBool(show.raw?['skipChildren'])) return true; + + final libraryFlattenSeasons = flexibleInt(libraryPrefs['flattenSeasons']); + return libraryFlattenSeasons == _plexFlattenSeasonsHide || + seasons.isEmpty || + (libraryFlattenSeasons == _plexFlattenSeasonsSingleSeason && seasons.length == 1); +} diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index e533b253..88bf919c 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -140,6 +140,23 @@ void main() { expect(item.isWatched, isTrue); }); + test('show preserves Plex season display flags in raw metadata', () { + final json = { + 'ratingKey': '500', + 'key': '/library/metadata/500', + 'type': 'show', + 'title': 'Breaking Bad', + 'skipChildren': '1', + 'flattenSeasons': '1', + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + + expect(item.raw, containsPair('key', '/library/metadata/500')); + expect(item.raw, containsPair('skipChildren', true)); + expect(item.raw, containsPair('flattenSeasons', 1)); + }); + test('season carries parent (show) reference', () { final json = { 'ratingKey': '510', diff --git a/test/utils/plex_season_display_test.dart b/test/utils/plex_season_display_test.dart new file mode 100644 index 00000000..7c80f55f --- /dev/null +++ b/test/utils/plex_season_display_test.dart @@ -0,0 +1,65 @@ +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/utils/plex_season_display.dart'; + +void main() { + group('shouldShowPlexEpisodesDirectly', () { + test('per-show hide override shows episodes directly', () { + final show = _show(raw: {'flattenSeasons': 1}); + + expect( + shouldShowPlexEpisodesDirectly(show: show, seasons: [_season('1'), _season('2')], libraryPrefs: {}), + isTrue, + ); + }); + + test('per-show show override keeps seasons despite hidden library default', () { + final show = _show(raw: {'flattenSeasons': 0}); + + expect( + shouldShowPlexEpisodesDirectly( + show: show, + seasons: [_season('1'), _season('2')], + libraryPrefs: {'flattenSeasons': 1}, + ), + isFalse, + ); + }); + + test('skipChildren hides seasons when no explicit show override exists', () { + final show = _show(raw: {'skipChildren': true}); + + expect( + shouldShowPlexEpisodesDirectly(show: show, seasons: [_season('1'), _season('2')], libraryPrefs: {}), + isTrue, + ); + }); + + test('falls back to library single-season mode', () { + final show = _show(); + + expect( + shouldShowPlexEpisodesDirectly(show: show, seasons: [_season('1')], libraryPrefs: {'flattenSeasons': '2'}), + isTrue, + ); + expect( + shouldShowPlexEpisodesDirectly( + show: show, + seasons: [_season('1'), _season('2')], + libraryPrefs: {'flattenSeasons': '2'}, + ), + isFalse, + ); + }); + }); +} + +MediaItem _show({Map? raw}) { + return MediaItem(id: 'show', backend: MediaBackend.plex, kind: MediaKind.show, raw: raw); +} + +MediaItem _season(String id) { + return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.season); +}