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, after2dfebf32band7ae293631had 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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user