fix(player): stop letting item metadata language override the audio track

The audio ladder still carried a per-item language tier between the
server's selected stream and the account preference. For Jellyfin and
Emby the value mapped to it was `PreferredMetadataLanguage`, which is the
library's metadata-scraping language - it inherits from the parent, the
library options, or the server config and says nothing about playback -
so on any source whose selected/default stream did not map to a native
track it silently picked audio in the scraping language instead of the
user's preferred one. Plex's item-level `audioLanguage` reached the same
tier, after 2dfebf32b and 7ae293631 had already settled that the server
folds every preference level into the stream `selected` flags.

Drop the tier and the plumbing that only fed it:
TrackSelectionPriority.perMedia, MediaItem.audioLanguage and
subtitleLanguage, PlexMetadataDto.audioLanguage/subtitleLanguage/
subtitleMode, and the Jellyfin PreferredMetadataLanguage mapping. The
order is now carried selection, server-selected or default source
stream, account preference, native default. Persisted cache entries
holding the old keys decode unchanged.
This commit is contained in:
edde746
2026-09-03 13:08:46 +02:00
parent 7e583ef05a
commit 42b6710f04
10 changed files with 39 additions and 110 deletions
-3
View File
@@ -177,8 +177,6 @@ sealed class MediaItem with _$MediaItem {
@JsonKey(fromJson: _mediaItemVersionsFromJson) List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? subtitleLanguage,
String? trailerKey,
@JsonKey(fromJson: flexibleInt) int? playlistItemId,
@JsonKey(fromJson: flexibleInt) int? playQueueItemId,
@@ -266,7 +264,6 @@ sealed class MediaItem with _$MediaItem {
@JsonKey(fromJson: _mediaItemVersionsFromJson) List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
/// Jellyfin playlist entry id used by playlist write endpoints.
String? playlistItemId,
File diff suppressed because one or more lines are too long
-6
View File
@@ -67,8 +67,6 @@ PlexMediaItem _$PlexMediaItemFromJson(Map<String, dynamic> json) =>
mediaVersions: _mediaItemVersionsFromJson(json['mediaVersions']),
libraryId: json['libraryId'] as String?,
libraryTitle: json['libraryTitle'] as String?,
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
trailerKey: json['trailerKey'] as String?,
playlistItemId: flexibleInt(json['playlistItemId']),
playQueueItemId: flexibleInt(json['playQueueItemId']),
@@ -135,8 +133,6 @@ Map<String, dynamic> _$PlexMediaItemToJson(PlexMediaItem instance) =>
'mediaVersions': ?instance.mediaVersions?.map((e) => e.toJson()).toList(),
'libraryId': ?instance.libraryId,
'libraryTitle': ?instance.libraryTitle,
'audioLanguage': ?instance.audioLanguage,
'subtitleLanguage': ?instance.subtitleLanguage,
'trailerKey': ?instance.trailerKey,
'playlistItemId': ?instance.playlistItemId,
'playQueueItemId': ?instance.playQueueItemId,
@@ -207,7 +203,6 @@ JellyfinMediaItem _$JellyfinMediaItemFromJson(Map<String, dynamic> json) =>
mediaVersions: _mediaItemVersionsFromJson(json['mediaVersions']),
libraryId: json['libraryId'] as String?,
libraryTitle: json['libraryTitle'] as String?,
audioLanguage: json['audioLanguage'] as String?,
playlistItemId: json['playlistItemId'] as String?,
serverId: json['serverId'] as String?,
serverName: json['serverName'] as String?,
@@ -270,7 +265,6 @@ Map<String, dynamic> _$JellyfinMediaItemToJson(JellyfinMediaItem instance) =>
'mediaVersions': ?instance.mediaVersions?.map((e) => e.toJson()).toList(),
'libraryId': ?instance.libraryId,
'libraryTitle': ?instance.libraryTitle,
'audioLanguage': ?instance.audioLanguage,
'playlistItemId': ?instance.playlistItemId,
'serverId': ?instance.serverId,
'serverName': ?instance.serverName,
-1
View File
@@ -292,7 +292,6 @@ class JellyfinMappers {
// stamps — scoped search, the Ancestors lookup, caller passthrough.
libraryId: null,
libraryTitle: null,
audioLanguage: item['PreferredMetadataLanguage'] as String?,
// Only present when the item came out of `/Playlists/{id}/Items`; the
// playlist write endpoints address rows by this id, not the media id.
playlistItemId: item['PlaylistItemId'] as String?,
-15
View File
@@ -650,10 +650,6 @@ class PlexMetadataDto {
final List<String>? format;
@JsonKey(name: 'Subformat', fromJson: _tagListFromJson, includeToJson: false)
final List<String>? subformat;
final String? audioLanguage;
final String? subtitleLanguage;
@JsonKey(fromJson: flexibleInt)
final int? subtitleMode;
@JsonKey(fromJson: flexibleInt)
final int? playlistItemID;
@JsonKey(fromJson: flexibleInt)
@@ -733,9 +729,6 @@ class PlexMetadataDto {
this.mood,
this.format,
this.subformat,
this.audioLanguage,
this.subtitleLanguage,
this.subtitleMode,
this.playlistItemID,
this.playQueueItemID,
this.librarySectionID,
@@ -860,9 +853,6 @@ class PlexMetadataDto {
List<String>? mood,
List<String>? format,
List<String>? subformat,
String? audioLanguage,
String? subtitleLanguage,
int? subtitleMode,
int? playlistItemID,
int? playQueueItemID,
int? librarySectionID,
@@ -934,9 +924,6 @@ class PlexMetadataDto {
mood: mood ?? this.mood,
format: format ?? this.format,
subformat: subformat ?? this.subformat,
audioLanguage: audioLanguage ?? this.audioLanguage,
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
subtitleMode: subtitleMode ?? this.subtitleMode,
playlistItemID: playlistItemID ?? this.playlistItemID,
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
librarySectionID: librarySectionID ?? this.librarySectionID,
@@ -1141,8 +1128,6 @@ class PlexMappers {
mediaVersions: dto.mediaVersions?.map(mediaVersion).toList(),
libraryId: dto.librarySectionID?.toString(),
libraryTitle: dto.librarySectionTitle,
audioLanguage: dto.audioLanguage,
subtitleLanguage: dto.subtitleLanguage,
trailerKey: dto.primaryExtraKey,
playlistItemId: dto.playlistItemID,
playQueueItemId: dto.playQueueItemID,
-6
View File
@@ -146,9 +146,6 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
mood: _tagListFromJson(json['Mood']),
format: _tagListFromJson(json['Format']),
subformat: _tagListFromJson(json['Subformat']),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: flexibleInt(json['subtitleMode']),
playlistItemID: flexibleInt(json['playlistItemID']),
playQueueItemID: flexibleInt(json['playQueueItemID']),
librarySectionID: flexibleInt(json['librarySectionID']),
@@ -206,9 +203,6 @@ Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
'viewedLeafCount': ?instance.viewedLeafCount,
'childCount': ?instance.childCount,
'imdbRatingCount': ?instance.imdbRatingCount,
'audioLanguage': ?instance.audioLanguage,
'subtitleLanguage': ?instance.subtitleLanguage,
'subtitleMode': ?instance.subtitleMode,
'playlistItemID': ?instance.playlistItemID,
'playQueueItemID': ?instance.playQueueItemID,
'librarySectionID': ?instance.librarySectionID,
+12 -22
View File
@@ -661,14 +661,16 @@ bool _titlesMatch(String? mpvTitle, String? plexTitle, String? plexDisplayTitle)
int _mediaTrackStreamIndex(int id, int? index) => index ?? id;
/// Priority levels for track selection
/// Priority levels for track selection. Per-item language overrides are not a
/// level: every backend folds them into the source's selected/default stream,
/// so an item-level language would only ever override the account preference
/// with something the server had already rejected.
enum TrackSelectionPriority {
navigation, // Priority 1: User's manual selection from previous episode
serverSelected, // Priority 2: server's pre-selected track
perMedia, // Priority 3: Per-media language preference
profile, // Priority 4: User profile preferences
defaultTrack, // Priority 5: Default or first track
off, // Priority 6: Subtitles off (subtitle only)
profile, // Priority 3: User profile preferences
defaultTrack, // Priority 4: Default or first track
off, // Priority 5: Subtitles off (subtitle only)
}
/// Result of track selection including the selected track and which priority was used
@@ -680,7 +682,7 @@ class TrackSelectionResult<T> {
}
/// Service for selecting and applying audio and subtitle tracks based on
/// preferences, user profiles, and per-media settings.
/// carried selections, server-selected streams, and account preferences.
class TrackSelectionService {
final Player? player;
final MediaServerUserProfile? profileSettings;
@@ -948,9 +950,8 @@ class TrackSelectionService {
/// Select the best audio track based on priority:
/// Priority 1: Preferred track from navigation
/// Priority 2: Server-selected track from media info
/// Priority 3: Per-media language preference
/// Priority 4: User profile preferences
/// Priority 5: Default or first track
/// Priority 3: User profile preferences
/// Priority 4: Default or first track
TrackSelectionResult<AudioTrack>? selectAudioTrack(
List<AudioTrack> availableTracks,
AudioTrack? preferredAudioTrack,
@@ -1021,18 +1022,7 @@ class TrackSelectionService {
}
}
// Priority 3: Try per-media language preference
if (metadata.audioLanguage != null) {
final matchedTrack = availableTracks.firstWhere(
(track) => languageMatches(track.language, metadata.audioLanguage),
orElse: () => availableTracks.first,
);
if (languageMatches(matchedTrack.language, metadata.audioLanguage)) {
return TrackSelectionResult(matchedTrack, TrackSelectionPriority.perMedia);
}
}
// Priority 4: Try user profile preferences
// Priority 3: Try user profile preferences
if (profileSettings != null) {
trackToSelect = findAudioTrackByProfile(availableTracks, profileSettings!);
if (trackToSelect != null) {
@@ -1040,7 +1030,7 @@ class TrackSelectionService {
}
}
// Priority 5: Use default or first track
// Priority 4: Use default or first track
trackToSelect = availableTracks.firstWhere((t) => t.isDefault, orElse: () => availableTracks.first);
return TrackSelectionResult(trackToSelect, TrackSelectionPriority.defaultTrack);
}
-4
View File
@@ -483,7 +483,6 @@ void main() {
MediaRatingSource(source: 'rottenTomatoesCritic', value: 9.4),
MediaRatingSource(source: 'imdb', value: 8.9, votes: 1200),
],
subtitleLanguage: 'eng',
trailerKey: '/library/metadata/1',
playlistItemId: 42,
playQueueItemId: 7,
@@ -495,7 +494,6 @@ void main() {
expect(copy.editionTitle, 'Director Cut');
expect(copy.ratings?.map((rating) => rating.source), ['rottenTomatoesCritic', 'imdb']);
expect(copy.ratings?.last.votes, 1200);
expect(copy.subtitleLanguage, 'eng');
expect(copy.trailerKey, '/library/metadata/1');
expect(copy.playlistItemId, 42);
expect(copy.playQueueItemId, 7);
@@ -547,7 +545,6 @@ void main() {
parts: [MediaPart(id: 'part1', streamPath: '/stream', sizeBytes: 1000)],
),
],
subtitleLanguage: 'eng',
trailerKey: '/trailer',
playlistItemId: 4,
playQueueItemId: 5,
@@ -567,7 +564,6 @@ void main() {
expect(plex.genres, ['Drama']);
expect(plex.roles?.single.tag, 'Actor');
expect(plex.mediaVersions?.single.parts.single.streamPath, '/stream');
expect(plex.subtitleLanguage, 'eng');
expect(plex.trailerKey, '/trailer');
expect(plex.playlistItemId, 4);
expect(plex.playQueueItemId, 5);
@@ -14,14 +14,8 @@ import '../test_helpers/media_items.dart';
// including fallback and ambiguity rules. `selectAndApplyTracks` is excluded
// because it requires a real Player and SettingsService singleton.
MediaItem _meta({MediaBackend backend = MediaBackend.plex, String? audioLanguage, String? subtitleLanguage}) =>
testMediaItem(
id: 'rk1',
backend: backend,
kind: MediaKind.movie,
audioLanguage: audioLanguage,
subtitleLanguage: subtitleLanguage,
);
MediaItem _meta({MediaBackend backend = MediaBackend.plex}) =>
testMediaItem(id: 'rk1', backend: backend, kind: MediaKind.movie);
AccountPreferences _profile({
bool autoSelectAudio = true,
@@ -373,15 +367,7 @@ void main() {
expect(result.track.language, 'fre');
});
test('Priority 3: per-media audioLanguage from metadata', () {
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
final result = _svc(metadata: _meta(audioLanguage: 'fre')).selectAudioTrack(tracks, null);
expect(result, isNotNull);
expect(result!.priority, TrackSelectionPriority.perMedia);
expect(result.track.language, 'fre');
});
test('Priority 4: user profile when nothing higher matches', () {
test('Priority 3: user profile when nothing higher matches', () {
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
final profile = _profile(defaultAudioLanguage: 'eng');
final result = _svc(profile: profile).selectAudioTrack(tracks, null);
@@ -390,7 +376,7 @@ void main() {
expect(result.track.language, 'eng');
});
test('Priority 5: default-flagged track as last resort', () {
test('Priority 4: default-flagged track as last resort', () {
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre', isDefault: true)];
final result = _svc().selectAudioTrack(tracks, null);
expect(result, isNotNull);
@@ -398,7 +384,7 @@ void main() {
expect(result.track.id, 'B');
});
test('Priority 5: first track when none flagged default', () {
test('Priority 4: first track when none flagged default', () {
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
final result = _svc().selectAudioTrack(tracks, null);
expect(result, isNotNull);
@@ -408,7 +394,7 @@ void main() {
test('preferred mismatch falls through to lower priority', () {
// preferred has a language that is NOT in the available tracks — Priority 1
// misses; Priority 5 picks the first track.
// misses; Priority 4 picks the first track.
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
final result = _svc().selectAudioTrack(tracks, _audio('Z', lang: 'jpn'));
expect(result, isNotNull);
-5
View File
@@ -63,8 +63,6 @@ MediaItem testMediaItem({
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? subtitleLanguage,
String? serverId,
String? serverName,
String? backendFolderKey,
@@ -129,8 +127,6 @@ MediaItem testMediaItem({
mediaVersions: mediaVersions,
libraryId: libraryId,
libraryTitle: libraryTitle,
audioLanguage: audioLanguage,
subtitleLanguage: subtitleLanguage,
serverId: serverId,
serverName: serverName,
backendFolderKey: backendFolderKey,
@@ -190,7 +186,6 @@ MediaItem testMediaItem({
mediaVersions: mediaVersions,
libraryId: libraryId,
libraryTitle: libraryTitle,
audioLanguage: audioLanguage,
serverId: serverId,
serverName: serverName,
backendFolderKey: backendFolderKey,