fix(plex): select available playback version
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user