From bf7558f80ddc1aa37fa4317b09fefbe9fbc7f835 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:45:09 +0200 Subject: [PATCH] fix(i18n): localize generic season names from server close #1271 --- lib/media/media_item.dart | 11 ++++- lib/media/media_item_types.dart | 6 +++ lib/media/season_title.dart | 26 ++++++++++ lib/screens/media_detail_screen.dart | 7 +-- lib/widgets/media_card.dart | 3 +- test/media/season_title_test.dart | 74 ++++++++++++++++++++++++++++ 6 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 lib/media/season_title.dart create mode 100644 test/media/season_title_test.dart diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 08c0cbb7..b280d0a2 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -10,6 +10,7 @@ import 'media_backend.dart'; import 'media_kind.dart'; import 'media_role.dart'; import 'media_version.dart'; +import 'season_title.dart'; part 'media_item.freezed.dart'; part 'media_item.g.dart'; @@ -401,8 +402,14 @@ sealed class MediaItem with _$MediaItem { /// 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)) { + if (kind == MediaKind.season) { + if (grandparentTitle != null || parentTitle != null) { + // Re-localize a server's generic English "Season N" (see #1271). + final label = localizedSeasonLabel(title: title, index: index); + return label.isNotEmpty ? label : title; + } + } else if (kind == MediaKind.episode) { + if (grandparentTitle != null) { return title; } } diff --git a/lib/media/media_item_types.dart b/lib/media/media_item_types.dart index 0196b4cf..5e40d28f 100644 --- a/lib/media/media_item_types.dart +++ b/lib/media/media_item_types.dart @@ -1,5 +1,6 @@ import 'media_item.dart'; import 'media_kind.dart'; +import 'season_title.dart'; /// Convenience type-check getters and spoiler helpers on [MediaItem]. These /// give consumers a Plex-style fluent API (e.g. `item.isShow`) while keeping @@ -27,4 +28,9 @@ extension MediaItemTypes on MediaItem { /// Non-spoiler art path for episodes (show/season background). String? get spoilerSafeArt => grandparentArtPath ?? artPath; + + /// Localized season label for this item. Re-localizes a server's generic + /// English "Season N" to the app locale (see [localizedSeasonLabel]); falls + /// back to [displayTitle] when there is no usable title or index. + String get localizedSeasonTitle => localizedSeasonLabel(title: title, index: index, fallback: displayTitle); } diff --git a/lib/media/season_title.dart b/lib/media/season_title.dart new file mode 100644 index 00000000..0a2a29f5 --- /dev/null +++ b/lib/media/season_title.dart @@ -0,0 +1,26 @@ +import '../i18n/strings.g.dart'; + +/// Matches a media server's generic English season title, e.g. "Season 1". +/// +/// Plex and Jellyfin return this verbatim when they have no localized name for a +/// season (or when the request language resolves to English), which leaks +/// untranslated "Season N" labels into otherwise-localized UI (see #1271). +final RegExp _genericSeasonTitle = RegExp(r'^season\s+(\d+)$', caseSensitive: false); + +/// Localized season label. +/// +/// Replaces a generic English "Season N" (or an empty title) with the app-locale +/// `t.common.seasonNumber`, while preserving custom names ("Specials", …) and +/// titles the server already localized ("Saison 1"). +/// +/// Prefers [index] for the number; falls back to the digits parsed from a +/// generic title, then to [fallback] when there is nothing usable to show. +String localizedSeasonLabel({String? title, int? index, String? fallback}) { + final raw = title?.trim() ?? ''; + final match = _genericSeasonTitle.firstMatch(raw); + final number = index ?? (match != null ? int.tryParse(match.group(1)!) : null); + if ((match != null || raw.isEmpty) && number != null) { + return t.common.seasonNumber(number: number); + } + return raw.isNotEmpty ? raw : (fallback ?? ''); +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index e173d28f..4ce9aae8 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -26,6 +26,7 @@ import '../media/media_hub.dart'; import '../utils/provider_extensions.dart'; import '../utils/plex_season_display.dart'; import '../media/media_item.dart'; +import '../media/season_title.dart'; import '../media/episode_collection.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; @@ -1669,7 +1670,7 @@ class _MediaDetailScreenState extends State id: seasonId, backend: _metadata.backend, kind: MediaKind.season, - title: firstEp.parentTitle ?? t.common.seasonNumber(number: entry.key), + title: localizedSeasonLabel(title: firstEp.parentTitle, index: entry.key), index: entry.key, leafCount: entry.value.length, thumbPath: firstEp.parentThumbPath, @@ -2335,7 +2336,7 @@ class _MediaDetailScreenState extends State onSecondaryTapDown: (details) => tapPosition = details.globalPosition, onSecondaryTap: () => _showSeasonTabContextMenu(index, position: tapPosition), child: FocusableTabChip( - label: season.title!, + label: season.localizedSeasonTitle, isSelected: index == _selectedSeasonIndex, topImage: topImage, focusNode: _seasonTabFocusNodes.length > index ? _seasonTabFocusNodes[index] : null, @@ -3739,7 +3740,7 @@ class _MediaDetailScreenState extends State hubs.add( MediaHub( id: '$_tvDetailSeasonHubIdPrefix$i', - title: season.title?.isNotEmpty == true ? season.title! : (season.displaySubtitle ?? season.displayTitle), + title: season.localizedSeasonTitle, type: 'episode', items: episodes, size: total, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 9c45b513..35707d22 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../focus/input_mode_tracker.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; +import '../media/season_title.dart'; import '../media/media_kind.dart'; import '../media/media_playlist.dart'; import '../mixins/context_menu_tap_mixin.dart'; @@ -133,7 +134,7 @@ class MediaCardState extends State with ContextMenuTapMixin LocaleSettings.setLocale(AppLocale.fr)); + tearDownAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + MediaItem season({String? title, int? index}) => MediaItem( + id: 'sn', + backend: MediaBackend.plex, + kind: MediaKind.season, + title: title, + index: index, + parentTitle: 'Scrubs', + serverId: 's1', + ); + + group('localizedSeasonLabel (fr locale)', () { + test('generic English "Season N" is re-localized', () { + expect(localizedSeasonLabel(title: 'Season 3', index: 3), 'Saison 3'); + }); + + test('case-insensitive match', () { + expect(localizedSeasonLabel(title: 'SEASON 1', index: 1), 'Saison 1'); + }); + + test('index is preferred over the number parsed from the title', () { + expect(localizedSeasonLabel(title: 'Season 99', index: 5), 'Saison 5'); + }); + + test('falls back to digits parsed from the title when index is null', () { + expect(localizedSeasonLabel(title: 'Season 3'), 'Saison 3'); + }); + + test('empty title with an index is localized', () { + expect(localizedSeasonLabel(title: '', index: 5), 'Saison 5'); + expect(localizedSeasonLabel(title: null, index: 5), 'Saison 5'); + }); + + test('already-localized title is preserved', () { + expect(localizedSeasonLabel(title: 'Saison 3', index: 3), 'Saison 3'); + }); + + test('custom season name is preserved', () { + expect(localizedSeasonLabel(title: 'Specials', index: 0), 'Specials'); + expect(localizedSeasonLabel(title: 'The Lost Episodes', index: 1), 'The Lost Episodes'); + }); + + test('nothing usable falls back to the provided fallback', () { + expect(localizedSeasonLabel(title: null, index: null, fallback: 'Unknown Season'), 'Unknown Season'); + expect(localizedSeasonLabel(title: ' ', index: null), ''); + }); + }); + + group('MediaItem.localizedSeasonTitle', () { + test('re-localizes a generic season title', () { + expect(season(title: 'Season 2', index: 2).localizedSeasonTitle, 'Saison 2'); + }); + + test('falls back to displayTitle when there is no usable title or index', () { + // displayTitle for a season prefers the show name (parentTitle). + expect(season(title: null, index: null).localizedSeasonTitle, 'Scrubs'); + }); + }); +}