diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index a6566d83..67c9aca2 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -10,7 +10,6 @@ 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'; @@ -421,14 +420,8 @@ sealed class MediaItem with _$MediaItem { /// Subtitle line shown below [displayTitle] for episodes/seasons. String? get displaySubtitle { - 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) { + if (kind == MediaKind.episode || kind == MediaKind.season) { + if (grandparentTitle != null || (kind == MediaKind.season && parentTitle != null)) { return title; } } diff --git a/lib/media/media_item_types.dart b/lib/media/media_item_types.dart index 5e40d28f..0196b4cf 100644 --- a/lib/media/media_item_types.dart +++ b/lib/media/media_item_types.dart @@ -1,6 +1,5 @@ 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 @@ -28,9 +27,4 @@ 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 deleted file mode 100644 index 0a2a29f5..00000000 --- a/lib/media/season_title.dart +++ /dev/null @@ -1,26 +0,0 @@ -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/models/plex/plex_config.dart b/lib/models/plex/plex_config.dart index b673deb4..8b511bc8 100644 --- a/lib/models/plex/plex_config.dart +++ b/lib/models/plex/plex_config.dart @@ -10,6 +10,7 @@ class PlexConfig { final String? device; final bool acceptJson; final String? machineIdentifier; + final String? languageCode; PlexConfig({ required this.baseUrl, @@ -21,6 +22,7 @@ class PlexConfig { this.device, this.acceptJson = true, this.machineIdentifier, + this.languageCode, }); static Future create({ @@ -32,6 +34,7 @@ class PlexConfig { String? device, bool acceptJson = true, String? machineIdentifier, + String? languageCode, }) async { final packageInfo = await PackageInfo.fromPlatform(); return PlexConfig( @@ -44,6 +47,7 @@ class PlexConfig { device: device, acceptJson: acceptJson, machineIdentifier: machineIdentifier, + languageCode: languageCode, ); } @@ -57,6 +61,8 @@ class PlexConfig { 'X-Plex-Device': ?device, if (acceptJson) 'Accept': 'application/json', 'Accept-Charset': 'utf-8', + 'Accept-Language': ?_normalizedLanguageCode, + 'X-Plex-Language': ?_normalizedLanguageCode, }; if (token != null) { @@ -66,6 +72,11 @@ class PlexConfig { return headers; } + String? get _normalizedLanguageCode { + final value = languageCode?.trim(); + return value == null || value.isEmpty ? null : value; + } + PlexConfig copyWith({ String? baseUrl, String? token, @@ -76,6 +87,7 @@ class PlexConfig { String? device, bool? acceptJson, String? machineIdentifier, + String? languageCode, }) { return PlexConfig( baseUrl: baseUrl ?? this.baseUrl, @@ -87,6 +99,7 @@ class PlexConfig { device: device ?? this.device, acceptJson: acceptJson ?? this.acceptJson, machineIdentifier: machineIdentifier ?? this.machineIdentifier, + languageCode: languageCode ?? this.languageCode, ); } } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index ff58f702..aeb9d061 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -26,7 +26,6 @@ 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'; @@ -1493,7 +1492,7 @@ class _MediaDetailScreenState extends State id: seasonId, backend: _metadata.backend, kind: MediaKind.season, - title: localizedSeasonLabel(title: firstEp.parentTitle, index: entry.key), + title: firstEp.parentTitle?.isNotEmpty == true ? firstEp.parentTitle : t.common.seasonNumber(number: entry.key), index: entry.key, leafCount: entry.value.length, thumbPath: firstEp.parentThumbPath, @@ -2250,7 +2249,7 @@ class _MediaDetailScreenState extends State onSecondaryTapDown: (details) => tapPosition = details.globalPosition, onSecondaryTap: () => _showSeasonTabContextMenu(index, position: tapPosition), child: FocusableTabChip( - label: season.localizedSeasonTitle, + label: season.title!, isSelected: index == _selectedSeasonIndex, topImage: topImage, focusNode: _seasonTabFocusNodes.length > index ? _seasonTabFocusNodes[index] : null, @@ -3684,7 +3683,7 @@ class _MediaDetailScreenState extends State hubs.add( MediaHub( id: '$_tvDetailSeasonHubIdPrefix$i', - title: season.localizedSeasonTitle, + title: season.title?.isNotEmpty == true ? season.title! : (season.displaySubtitle ?? season.displayTitle), type: 'episode', items: episodes, size: total, diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index e419c77c..16076b38 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; +import '../../providers/multi_server_provider.dart'; import '../../providers/theme_provider.dart'; import '../../profiles/active_profile_provider.dart'; import '../../navigation/navigation_tabs.dart'; @@ -198,6 +199,9 @@ class AppearanceSettingsScreen extends StatelessWidget { if (value != null) { await SettingsService.instance.write(SettingsService.appLocale, value); unawaited(LocaleSettings.setLocale(value)); + if (context.mounted) { + context.read().serverManager.updatePlexLanguage(value.languageCode); + } if (context.mounted) _restartApp(context); } }, diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index bc0a4755..304b52ec 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -15,6 +15,7 @@ import '../utils/media_server_timeouts.dart'; import '../utils/future_extensions.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'plex_auth_service.dart'; +import 'settings_service.dart'; import 'storage_service.dart'; /// Manages multiple media-server connections simultaneously. @@ -120,6 +121,16 @@ class MultiServerManager { return client is PlexClient ? client : null; } + void updatePlexLanguage(String languageCode) { + for (final client in _clients.values) { + if (client is PlexClient) { + client.applyLanguageUpdate(languageCode); + } + } + } + + String? get _currentPlexLanguageCode => SettingsService.instanceOrNull?.read(SettingsService.appLocale).languageCode; + @visibleForTesting void debugRegisterJellyfinClientForTesting(JellyfinClient client, {bool online = true}) { _wireJellyfinConnectionUpdates(client); @@ -256,6 +267,7 @@ class MultiServerManager { baseUrl: baseUrl, token: server.accessToken, clientIdentifier: clientIdentifier, + languageCode: _currentPlexLanguageCode, ); final client = await PlexClient.create( diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 1f88683d..4b3eae23 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3309,6 +3309,14 @@ class PlexClient await _initMediaProviders(); } + /// Apply the app locale to future Plex API requests. PMS localizes standard + /// server-provided labels (hubs, generic seasons, etc.) from these headers. + void applyLanguageUpdate(String languageCode) { + if (config.languageCode == languageCode) return; + config = config.copyWith(languageCode: languageCode); + _http.defaultHeaders = Map.of(config.headers); + } + // ──────────────────────────────────────────────────────────────────── // MediaServerClient implementation // diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 5da7181b..30cb5ce1 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -6,7 +6,6 @@ import '../focus/focusable_wrapper.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; -import '../media/season_title.dart'; import '../models/download_models.dart'; import '../utils/dialogs.dart'; import '../utils/global_key_utils.dart'; @@ -189,8 +188,12 @@ class _DownloadTreeViewState extends State { // Get season metadata from first episode final firstEpisode = widget.metadata[seasonEpisodes.first.key]; - final seasonTitle = firstEpisode?.parentTitle ?? 'Unknown Season'; final seasonNumber = firstEpisode?.parentIndex; + final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true + ? firstEpisode!.parentTitle! + : seasonNumber != null + ? t.common.seasonNumber(number: seasonNumber) + : 'Unknown Season'; // Build episode nodes final List episodeNodes = []; @@ -232,16 +235,10 @@ class _DownloadTreeViewState extends State { : episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length; final seasonStatus = _determineAggregateStatus(episodeNodes.map((e) => e.status).toList()); - final displayTitle = localizedSeasonLabel( - title: firstEpisode?.parentTitle, - index: seasonNumber, - fallback: seasonTitle, - ); - seasons.add( DownloadTreeNode( key: '$showKey:$seasonKey', - title: displayTitle, + title: seasonTitle, type: DownloadNodeType.season, progress: seasonProgress, status: seasonStatus, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 138f05d6..4738579c 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -8,7 +8,6 @@ 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'; @@ -120,7 +119,11 @@ 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'); - }); - }); -} diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index 90392d92..c4a2a24b 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -29,6 +29,37 @@ class _SequenceClient extends http.BaseClient { } void main() { + group('PlexConfig language headers', () { + test('includes Plex language headers when configured', () { + final config = PlexConfig( + baseUrl: 'http://server:32400', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + languageCode: 'fr', + ); + + expect(config.headers['Accept-Language'], 'fr'); + expect(config.headers['X-Plex-Language'], 'fr'); + }); + + test('copyWith preserves language headers when refreshing the token', () { + final config = PlexConfig( + baseUrl: 'http://server:32400', + token: 'old-token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + languageCode: 'es', + ).copyWith(token: 'new-token'); + + expect(config.headers['X-Plex-Token'], 'new-token'); + expect(config.headers['Accept-Language'], 'es'); + expect(config.headers['X-Plex-Language'], 'es'); + }); + }); + group('PlexClient home hub retries', () { test('fetchGlobalHubs retries a transient first failure', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); @@ -63,6 +94,67 @@ void main() { expect(httpClient.requests.map((r) => r.url.queryParameters['count']), everyElement('12')); }); + test('fetchGlobalHubs sends configured Plex language headers', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + final httpClient = _SequenceClient([(_) async => _jsonResponse(_globalHubsPayload())]); + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'http://server:32400', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + languageCode: 'fr', + ), + serverId: ServerId('server-id'), + serverName: 'Server', + httpClient: httpClient, + ); + addTearDown(client.close); + + await client.fetchGlobalHubs(limit: 12); + + expect(httpClient.requests.single.headers['Accept-Language'], 'fr'); + expect(httpClient.requests.single.headers['X-Plex-Language'], 'fr'); + }); + + test('applyLanguageUpdate refreshes headers on the live HTTP client', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + final httpClient = _SequenceClient([ + (_) async => _jsonResponse(_globalHubsPayload()), + (_) async => _jsonResponse(_globalHubsPayload()), + ]); + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'http://server:32400', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + languageCode: 'en', + ), + serverId: ServerId('server-id'), + serverName: 'Server', + httpClient: httpClient, + ); + addTearDown(client.close); + + await client.fetchGlobalHubs(limit: 12); + client.applyLanguageUpdate('fr'); + await client.fetchGlobalHubs(limit: 12); + + expect(httpClient.requests[0].headers['Accept-Language'], 'en'); + expect(httpClient.requests[0].headers['X-Plex-Language'], 'en'); + expect(httpClient.requests[1].headers['Accept-Language'], 'fr'); + expect(httpClient.requests[1].headers['X-Plex-Language'], 'fr'); + }); + test('fetchGlobalHubs retries transient failures without switching Plex endpoints', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db);