diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index cc7e4128..c4cf0b8a 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -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, diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 5d41738d..d3afae4e 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -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); diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index cf5b0999..439ea8db 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -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), ); diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 527a7092..23c1ef6d 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -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 diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart index 00a768b1..4f9082b0 100644 --- a/lib/screens/video_player/parts/seeking.dart +++ b/lib/screens/video_player/parts/seeking.dart @@ -65,7 +65,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { final playbackService = PlaybackInitializationService(client: mediaClient, database: context.read()); 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; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 8ecc0b77..0b4b6719 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -265,6 +265,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isPhone = false; List _availableVersions = []; MediaSourceInfo? _currentMediaInfo; + late int _effectiveSelectedMediaIndex; // Transcode / quality state late TranscodeQualityPreset _selectedQualityPreset; @@ -459,6 +460,7 @@ class VideoPlayerScreenState extends State 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. diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index eb6f659f..b766045d 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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'], ); } diff --git a/lib/services/jellyfin_playback_bundle.dart b/lib/services/jellyfin_playback_bundle.dart index 05a2f07e..7b2865c1 100644 --- a/lib/services/jellyfin_playback_bundle.dart +++ b/lib/services/jellyfin_playback_bundle.dart @@ -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, }); diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index f005592f..43ba5527 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -217,6 +217,7 @@ class PlaybackInitializationService { externalSubtitles: sidecarSubtitles, isOffline: true, playMethod: 'DirectPlay', + selectedMediaIndex: selectedMediaIndex, ); } diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index d84a33d7..d48615d1 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -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, }); } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 161e6a46..34c0ed1f 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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; diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart index c66b4d05..718041d7 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -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 mediaList, + List versions, + int selectedMediaIndex, + int selectedPartIndex, +) { + final candidateCount = mediaList.fold(0, (count, media) => count + _mapList(media['Part']).length); + if (candidateCount <= 1) return; + + final entries = []; + 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? 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(); diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 3960ee12..13935dd6 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -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 = []; + 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)['Metadata'] as List; + final item = metadata.single as Map; + final media = item['Media'] as List; + final part = ((media.single as Map)['Part'] as List).single as Map; + + 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 = []; + 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); diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart index 1fda8e42..53c1a212 100644 --- a/test/services/plex_playback_mapper_test.dart +++ b/test/services/plex_playback_mapper_test.dart @@ -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( {