fix(plex): select available playback version

This commit is contained in:
edde746
2026-05-31 02:21:15 +02:00
parent 63ab80fa82
commit 9a740b91c1
14 changed files with 292 additions and 4 deletions
+1 -1
View File
@@ -259,7 +259,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onNext: onNext,
onPrevious: onPrevious,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
selectedQualityPreset: _selectedQualityPreset,
serverSupportsTranscoding: _serverSupportsTranscoding,
@@ -163,8 +163,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
final requestedMediaIndex = _effectiveSelectedMediaIndex;
_currentMetadata = episodeMetadata;
VideoPlayerScreenState._activeId = episodeMetadata.id;
VideoPlayerScreenState._activeMediaIndex = requestedMediaIndex;
_showPlayNextDialog = false;
_autoPlayTimer?.cancel();
_hasFirstFrame.value = false;
@@ -173,7 +175,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database);
final playbackContext = await playbackResolver.resolve(
metadata: episodeMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaIndex: requestedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
offlineLibraryMode: widget.isOffline,
qualityPreset: _selectedQualityPreset,
@@ -198,6 +200,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
_selectedAudioStreamId = result.activeAudioStreamId;
_effectiveSelectedMediaIndex = result.selectedMediaIndex;
if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
+1 -1
View File
@@ -37,7 +37,7 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
_videoFilterManager = VideoFilterManager(
player: currentPlayer,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaIndex: _effectiveSelectedMediaIndex,
initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode),
onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode),
);
@@ -185,6 +185,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
_selectedQualityPreset = TranscodeQualityPreset.original;
}
}
_effectiveSelectedMediaIndex = result.selectedMediaIndex;
_playbackContext = playbackContext;
// Primary refresh-rate path: when metadata provides FPS, Android players
+2 -1
View File
@@ -65,7 +65,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
final playbackService = PlaybackInitializationService(client: mediaClient, database: context.read<AppDatabase>());
final result = await playbackService.getPlaybackData(
metadata: replacementMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
preferOffline: false,
qualityPreset: _selectedQualityPreset,
@@ -84,6 +84,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
_selectedAudioStreamId = result.activeAudioStreamId;
_effectiveSelectedMediaIndex = result.selectedMediaIndex;
_availableVersions = result.availableVersions;
_currentMediaInfo = result.mediaInfo;
+2
View File
@@ -265,6 +265,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _isPhone = false;
List<MediaVersion> _availableVersions = [];
MediaSourceInfo? _currentMediaInfo;
late int _effectiveSelectedMediaIndex;
// Transcode / quality state
late TranscodeQualityPreset _selectedQualityPreset;
@@ -459,6 +460,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_currentMetadata = widget.metadata;
_activeId = widget.metadata.id;
_activeMediaIndex = widget.selectedMediaIndex;
_effectiveSelectedMediaIndex = widget.selectedMediaIndex;
// Reused across quality/version/audio switches so the server-side
// transcode session is preserved.
@@ -244,6 +244,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
activeAudioStreamId: requestedAudioStreamId,
playSessionId: playSessionId,
playMethod: playMethod,
selectedMediaIndex: bundle.selectedSourceIndex,
);
}
@@ -384,6 +385,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
chapters: chapters is List ? chapters : const [],
container: source['Container'] as String?,
selectedSourceId: source['Id'] as String?,
selectedSourceIndex: index,
trickplay: raw['Trickplay'],
);
}
@@ -30,6 +30,9 @@ class JellyfinPlaybackBundle {
/// falls back to its first sorted source instead of the selected version.
final String? selectedSourceId;
/// Effective source index after source-id matching and range clamping.
final int selectedSourceIndex;
/// Item-level `Trickplay` manifest (raw JSON object). `null` when the
/// server hasn't run trickplay extraction for this item.
final Object? trickplay;
@@ -40,6 +43,7 @@ class JellyfinPlaybackBundle {
required this.chapters,
this.container,
this.selectedSourceId,
this.selectedSourceIndex = 0,
this.trickplay,
});
@@ -217,6 +217,7 @@ class PlaybackInitializationService {
externalSubtitles: sidecarSubtitles,
isOffline: true,
playMethod: 'DirectPlay',
selectedMediaIndex: selectedMediaIndex,
);
}
@@ -77,6 +77,9 @@ class PlaybackInitializationResult {
/// expects one of `DirectPlay`, `DirectStream`, or `Transcode`.
final String? playMethod;
/// Effective media version after backend clamping/fallback.
final int selectedMediaIndex;
/// True when [videoUrl] points at a downloaded/local copy. This is a media
/// source detail, not a statement about whether server reporting is possible.
bool get usesLocalMedia => isOffline;
@@ -92,6 +95,7 @@ class PlaybackInitializationResult {
this.activeAudioStreamId,
this.playSessionId,
this.playMethod,
this.selectedMediaIndex = 0,
});
}
+3
View File
@@ -3284,6 +3284,7 @@ class PlexClient
isTranscoding: true,
activeAudioStreamId: resolvedAudioId,
playMethod: 'Transcode',
selectedMediaIndex: data.selectedMediaIndex,
);
}
@@ -3302,6 +3303,7 @@ class PlexClient
isTranscoding: false,
fallbackReason: fallbackReason,
playMethod: 'DirectPlay',
selectedMediaIndex: data.selectedMediaIndex,
);
}
@@ -3312,6 +3314,7 @@ class PlexClient
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
isOffline: false,
playMethod: 'DirectPlay',
selectedMediaIndex: data.selectedMediaIndex,
);
} catch (e) {
if (e is PlaybackException) rethrow;
+32
View File
@@ -2,6 +2,7 @@ import '../media/media_file_info.dart';
import '../media/media_source_info.dart';
import '../media/media_version.dart';
import '../models/plex/plex_video_playback_data.dart';
import '../utils/app_logger.dart';
import '../utils/json_utils.dart';
import '../utils/plex_url_helper.dart';
import 'file_info_parser.dart';
@@ -25,6 +26,36 @@ int _firstPlayablePartIndex(MediaVersion version) {
return playable >= 0 ? playable : 0;
}
void _logPartSelection(
List<Map> mediaList,
List<MediaVersion> versions,
int selectedMediaIndex,
int selectedPartIndex,
) {
final candidateCount = mediaList.fold<int>(0, (count, media) => count + _mapList(media['Part']).length);
if (candidateCount <= 1) return;
final entries = <String>[];
for (var mediaIndex = 0; mediaIndex < mediaList.length; mediaIndex++) {
final partList = _mapList(mediaList[mediaIndex]['Part']);
for (var partIndex = 0; partIndex < partList.length; partIndex++) {
final part = partList[partIndex];
final versionPart = mediaIndex < versions.length && partIndex < versions[mediaIndex].parts.length
? versions[mediaIndex].parts[partIndex]
: null;
final selected = mediaIndex == selectedMediaIndex && partIndex == selectedPartIndex ? ' selected' : '';
entries.add(
'Media[$mediaIndex].Part[$partIndex] '
'id=${part['id']} key=${part['key']} '
'exists=${versionPart?.exists} accessible=${versionPart?.accessible} '
'playable=${versionPart?.isPlayable}$selected',
);
}
}
appLogger.d('Plex playback part selection: ${entries.join('; ')}');
}
PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
Map<String, dynamic>? metadataJson, {
required String baseUrl,
@@ -64,6 +95,7 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
if (partList.isNotEmpty) {
selectedPartIndex = _firstPlayablePartIndex(availableVersions[mediaIndex]);
if (selectedPartIndex < 0 || selectedPartIndex >= partList.length) selectedPartIndex = 0;
_logPartSelection(mediaList, availableVersions, selectedMediaIndex, selectedPartIndex);
final part = partList[selectedPartIndex];
final partKey = part['key']?.toString();
@@ -5,10 +5,14 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
@@ -107,11 +111,211 @@ void main() {
expect(requests, hasLength(1));
expect(requests.single.queryParameters['includeStreams'], '1');
expect(requests.single.queryParameters['checkFiles'], '1');
expect(requests.single.queryParameters.containsKey('checkFileAvailability'), isFalse);
expect(data.mediaInfo?.subtitleTracks, hasLength(1));
expect(data.mediaInfo?.subtitleTracks.single.id, 401);
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
});
test('playback uses metadata availability flags without probing part URLs', () async {
final requests = <http.Request>[];
final client = makeClient((request) async {
requests.add(request);
if (request.url.path != '/library/metadata/42') {
return http.Response('unexpected request', 500);
}
return http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Movie',
'Media': [
{
'id': 7,
'container': 'mkv',
'Part': [
{'id': 10, 'key': '/library/parts/10/file.mkv', 'exists': 0, 'accessible': 1},
{'id': 20, 'key': '/library/parts/20/file.mkv', 'exists': 1, 'accessible': 1},
],
},
],
},
],
},
}),
200,
headers: {'content-type': 'application/json'},
);
});
addTearDown(client.close);
final data = await client.getVideoPlaybackData('42');
expect(requests, hasLength(1));
expect(requests.single.url.queryParameters['checkFiles'], '1');
expect(requests.single.url.queryParameters.containsKey('checkFileAvailability'), isFalse);
expect(data.videoUrl, 'https://plex.example.com/library/parts/20/file.mkv?X-Plex-Token=token');
expect(data.selectedMediaIndex, 0);
expect(data.selectedPartIndex, 1);
});
test('latest server metadata overwrites cached playback media fields', () async {
final cache = PlexApiCache.instance;
await cache.put('server-id', '/library/metadata/42', {
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Playback title',
'Media': [
{
'id': 7,
'Part': [
{
'id': 99,
'key': '/library/parts/99/file.mkv',
'exists': true,
'accessible': true,
'Stream': [
{'streamType': 1, 'id': 300, 'codec': 'h264'},
],
},
],
},
],
},
],
},
});
await cache.put('server-id', '/library/metadata/42', {
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Detail title',
'Media': [
{
'id': 7,
'Part': [
{'id': 99, 'key': '/library/parts/99/weak.mkv'},
],
},
],
},
],
},
});
final cached = await cache.get('server-id', '/library/metadata/42');
final metadata = (cached!['MediaContainer'] as Map<String, dynamic>)['Metadata'] as List<dynamic>;
final item = metadata.single as Map<String, dynamic>;
final media = item['Media'] as List<dynamic>;
final part = ((media.single as Map<String, dynamic>)['Part'] as List<dynamic>).single as Map<String, dynamic>;
expect(item['title'], 'Detail title');
expect(part['key'], '/library/parts/99/weak.mkv');
expect(part.containsKey('exists'), isFalse);
expect(part.containsKey('accessible'), isFalse);
expect(part.containsKey('Stream'), isFalse);
});
test('network failure falls back to lean cached playback metadata', () async {
await PlexApiCache.instance.put('server-id', '/library/metadata/42', {
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Movie',
'Media': [
{
'id': 7,
'Part': [
{'id': 10, 'key': '/library/parts/10/stale.mkv'},
],
},
{
'id': 8,
'Part': [
{'id': 20, 'key': '/library/parts/20/current.mkv'},
],
},
],
},
],
},
});
final requests = <http.Request>[];
final client = makeClient((request) async {
requests.add(request);
throw Exception('offline');
});
addTearDown(client.close);
final data = await client.getVideoPlaybackData('42');
expect(requests, hasLength(1));
expect(data.videoUrl, 'https://plex.example.com/library/parts/10/stale.mkv?X-Plex-Token=token');
expect(data.availableVersions, hasLength(2));
});
test('playback initialization exposes effective selected media index', () async {
final client = makeClient((request) async {
if (request.url.path != '/library/metadata/42') {
return http.Response('unexpected request', 500);
}
return http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Movie',
'Media': [
{
'id': 7,
'Part': [
{'id': 10, 'key': '/library/parts/10/stale.mkv', 'exists': false, 'accessible': false},
],
},
{
'id': 8,
'Part': [
{'id': 20, 'key': '/library/parts/20/current.mkv', 'exists': true, 'accessible': true},
],
},
],
},
],
},
}),
200,
headers: {'content-type': 'application/json'},
);
});
addTearDown(client.close);
final result = await client.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: MediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
selectedMediaIndex: 0,
),
);
expect(result.videoUrl, 'https://plex.example.com/library/parts/20/current.mkv?X-Plex-Token=token');
expect(result.selectedMediaIndex, 1);
});
test('transcode subtitle sidecars only use real Plex stream keys', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
@@ -50,6 +50,37 @@ void main() {
expect(result.selectedPartIndex, 0);
});
test('falls back when first Plex media has unavailable part flags', () {
final result = parsePlexVideoPlaybackDataFromJson(
{
'Media': [
{
'id': 9773,
'videoResolution': '1080',
'Part': [
{'id': 9815, 'key': '/library/parts/9815/1774877382/file.mp4', 'accessible': false, 'exists': false},
],
},
{
'id': 9766,
'videoResolution': '720',
'Part': [
{'id': 9808, 'key': '/library/parts/9808/1775431760/file.mp4', 'accessible': true, 'exists': true},
],
},
],
},
baseUrl: 'http://plex:32400',
token: 'tok',
);
expect(result.videoUrl, 'http://plex:32400/library/parts/9808/1775431760/file.mp4?X-Plex-Token=tok');
expect(result.selectedMediaIndex, 1);
expect(result.selectedPartIndex, 0);
expect(result.availableVersions.first.isPlayable, isFalse);
expect(result.availableVersions.last.isPlayable, isTrue);
});
test('uses playable part when the first part is unavailable', () {
final result = parsePlexVideoPlaybackDataFromJson(
{