fix(plex): respect per-show season display

close #1008
This commit is contained in:
edde746
2026-05-11 12:55:59 +02:00
parent d0bd919ff4
commit fd1eaf9d96
6 changed files with 136 additions and 10 deletions
+8 -9
View File
@@ -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<MediaDetailScreen>
.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;
}
+19 -1
View File
@@ -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<String, dynamic> 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<String, Object?>? _rawMetadata(PlexMetadataDto dto) {
final raw = <String, Object?>{};
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),
);
}
+4
View File
@@ -145,6 +145,8 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> 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<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
@@ -201,4 +203,6 @@ Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
'primaryExtraKey': ?instance.primaryExtraKey,
'clearLogo': ?instance.clearLogo,
'backgroundSquare': ?instance.backgroundSquare,
'skipChildren': ?instance.skipChildren,
'flattenSeasons': ?instance.flattenSeasons,
};
+23
View File
@@ -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<MediaItem> seasons,
required Map<String, dynamic> 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);
}
+17
View File
@@ -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',
+65
View File
@@ -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<String, Object?>? 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);
}