fix(i18n): request localized Plex labels

close #1311
This commit is contained in:
edde746
2026-06-12 07:59:35 +02:00
parent 03c623e8c7
commit cceba28d0a
12 changed files with 145 additions and 130 deletions
+2 -9
View File
@@ -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;
}
}
-6
View File
@@ -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);
}
-26
View File
@@ -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 ?? '');
}
+13
View File
@@ -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<PlexConfig> 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,
);
}
}
+3 -4
View File
@@ -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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
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,
@@ -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<MultiServerProvider>().serverManager.updatePlexLanguage(value.languageCode);
}
if (context.mounted) _restartApp(context);
}
},
+12
View File
@@ -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(
+8
View File
@@ -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
//
+6 -9
View File
@@ -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<DownloadTreeView> {
// 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<DownloadTreeNode> episodeNodes = [];
@@ -232,16 +235,10 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
: 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,
+5 -2
View File
@@ -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<MediaCard> with ContextMenuTapMixin<MediaCard
final episodeInfo = item.parentIndex != null && item.index != null ? 'S${item.parentIndex} E${item.index}' : '';
baseLabel = t.accessibility.mediaCardEpisode(title: item.displayTitle, episodeInfo: episodeInfo);
case MediaKind.season:
final seasonInfo = localizedSeasonLabel(title: item.title, index: item.index);
final seasonInfo = item.title?.isNotEmpty == true
? item.title!
: item.index != null
? t.common.seasonNumber(number: item.index!)
: '';
baseLabel = t.accessibility.mediaCardSeason(title: item.displayTitle, seasonInfo: seasonInfo);
case MediaKind.movie:
baseLabel = t.accessibility.mediaCardMovie(title: item.displayTitle);
-74
View File
@@ -1,74 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_item_types.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/season_title.dart';
/// Pins the #1271 behavior: a server's generic English "Season N" title is
/// re-localized to the current app locale, while custom / already-localized
/// names pass through untouched.
void main() {
// Locales are lazy-loaded, so the non-base locale must be set asynchronously.
setUpAll(() => 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');
});
});
}
+92
View File
@@ -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);