From ee3c75ba9eab350d42666b309b3909a9dd72122f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:11:18 +0200 Subject: [PATCH] fix(playback): remember last played media version for continue watching close #1492 --- lib/media/media_item.dart | 13 ++ lib/media/media_version_preference.dart | 73 ++++++++ .../parts/episode_navigation.dart | 12 +- .../video_player/parts/watch_together.dart | 8 +- lib/screens/video_player_screen.dart | 15 ++ lib/services/data_aggregation_service.dart | 59 +++++- lib/services/jellyfin_client.dart | 1 + .../parts/images_downloads.dart | 7 +- .../jellyfin_client/parts/playback.dart | 21 ++- lib/services/local_playback_history.dart | 69 +++++++ .../playback_initialization_service.dart | 2 + .../playback_initialization_types.dart | 8 + lib/services/playback_source_resolver.dart | 2 + lib/services/plex_client.dart | 30 +++- lib/services/plex_playback_mapper.dart | 20 +++ lib/services/settings_service.dart | 15 +- lib/utils/video_player_navigation.dart | 106 +++++++++-- lib/widgets/media_context_menu.dart | 7 + test/media/media_version_preference_test.dart | 57 ++++++ .../data_aggregation_bridge_test.dart | 169 ++++++++++++++++++ .../jellyfin_playback_bundle_test.dart | 43 +++++ .../services/local_playback_history_test.dart | 85 +++++++++ test/services/plex_playback_mapper_test.dart | 134 ++++++++++++++ test/utils/video_player_navigation_test.dart | 107 +++++++++++ 24 files changed, 1033 insertions(+), 30 deletions(-) create mode 100644 lib/media/media_version_preference.dart create mode 100644 lib/services/local_playback_history.dart create mode 100644 test/media/media_version_preference_test.dart create mode 100644 test/services/local_playback_history_test.dart diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index a180cd4c..c94bfee4 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -377,6 +377,19 @@ sealed class MediaItem with _$MediaItem { String? get libraryGlobalKey => serverId != null && libraryId != null ? buildGlobalKey(ServerId(serverId!), libraryId!) : null; + /// Global unique identifier of this item's series, for episodes/seasons. + /// Null for movies and shows themselves — their own [globalKey] is already + /// series-level. + String? get seriesGlobalKey { + final seriesId = switch (kind) { + MediaKind.episode => grandparentId, + MediaKind.season => grandparentId ?? parentId, + _ => null, + }; + if (seriesId == null) return null; + return serverId != null ? buildGlobalKey(ServerId(serverId!), seriesId) : seriesId; + } + /// Parent rating keys for hierarchical invalidation. For an episode: /// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`. List get parentChain => [?parentId, ?grandparentId]; diff --git a/lib/media/media_version_preference.dart b/lib/media/media_version_preference.dart new file mode 100644 index 00000000..2a3f5f0d --- /dev/null +++ b/lib/media/media_version_preference.dart @@ -0,0 +1,73 @@ +import 'media_version.dart'; + +/// A remembered media-version choice for a series or standalone item, stored +/// in `SettingsService.mediaVersionPreferences` (#1492). +/// +/// Persisted as `{"id":…,"sig":…,"idx":…,"at":…}`. Values written before the +/// record form existed were bare positional ints; [MediaVersionPreference.fromJson] +/// still accepts those so old preferences keep working. +class MediaVersionPreference { + /// Backend-opaque [MediaVersion.id] of the chosen version. Exact match only + /// holds for the item the pick was made on (ids differ per episode). + final String? versionId; + + /// [MediaVersion.signature] ("res:codec:container") of the chosen version, + /// for matching the equivalent version on sibling episodes. + final String? signature; + + /// Positional index into the Media list at pick time. Last-resort fallback + /// and the only field legacy int values carry. + final int index; + + /// Epoch ms of the last write; used to evict the oldest entries when the + /// preference map is pruned. Null on legacy entries (evicted first). + final int? updatedAt; + + const MediaVersionPreference({this.versionId, this.signature, required this.index, this.updatedAt}); + + /// Capture [version] (at [index] in its Media list) as a preference. + factory MediaVersionPreference.forVersion(MediaVersion version, int index) => MediaVersionPreference( + versionId: version.id.isEmpty ? null : version.id, + signature: version.signature, + index: index, + updatedAt: DateTime.now().millisecondsSinceEpoch, + ); + + factory MediaVersionPreference.fromJson(Object? raw) { + if (raw is int) return MediaVersionPreference(index: raw); + if (raw is Map) { + return MediaVersionPreference( + versionId: raw['id'] as String?, + signature: raw['sig'] as String?, + index: raw['idx'] is int ? raw['idx'] as int : 0, + updatedAt: raw['at'] as int?, + ); + } + return const MediaVersionPreference(index: 0); + } + + Map toJson() => { + if (versionId != null) 'id': versionId, + if (signature != null) 'sig': signature, + 'idx': index, + if (updatedAt != null) 'at': updatedAt, + }; + + /// Resolve this preference against an actual version list: exact id match, + /// then signature match (3-tier, see [MediaVersion.findMatchingIndex]), then + /// the stored index when still in range. Null when nothing applies. + int? resolveIndex(List versions) { + if (versions.isEmpty) return null; + final id = versionId; + if (id != null && id.isNotEmpty) { + final byId = versions.indexWhere((v) => v.id == id); + if (byId >= 0) return byId; + } + final sig = signature; + if (sig != null && sig.isNotEmpty) { + final bySignature = MediaVersion.findMatchingIndex(versions, {sig}); + if (bySignature != null) return bySignature; + } + return index >= 0 && index < versions.length ? index : null; + } +} diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index fcacae9b..b7f1d70e 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -108,10 +108,18 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { return; } + // Carry the playing version to the next episode by signature — its Media + // list may order versions differently, so the bare index is a guess and + // the source id is per-episode. + final currentVersionSignature = + _effectiveSelectedMediaIndex >= 0 && _effectiveSelectedMediaIndex < _availableVersions.length + ? _availableVersions[_effectiveSelectedMediaIndex].signature + : null; await _reloadMediaInPlace( metadata: episodeMetadata, selectedMediaIndex: _effectiveSelectedMediaIndex, selectedMediaSourceId: null, + preferredVersionSignature: currentVersionSignature, qualityPreset: _selectedQualityPreset, // Stream ids are per-part: the previous episode's audio id is // meaningless on the new item, so let preferences pick the track. @@ -160,7 +168,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { try { if (isVersionChange) { - await saveMediaVersionIndexFor(_currentMetadata, effectiveMediaIndex); + await saveMediaVersionPreferenceFor(_currentMetadata, index: effectiveMediaIndex, versions: _availableVersions); } if (isSubtitleChange || (isAudioChange && isPlexBacked)) { @@ -206,6 +214,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { required MediaItem metadata, int? selectedMediaIndex, String? selectedMediaSourceId, + String? preferredVersionSignature, TranscodeQualityPreset? qualityPreset, int? selectedAudioStreamId, Duration? resumePosition, @@ -306,6 +315,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { metadata: metadata, selectedMediaIndex: targetMediaIndex, selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, offlineLibraryMode: _offlineLibraryMode, qualityPreset: targetQualityPreset, selectedAudioStreamId: targetAudioStreamId, diff --git a/lib/screens/video_player/parts/watch_together.dart b/lib/screens/video_player/parts/watch_together.dart index 2e150c25..4759c905 100644 --- a/lib/screens/video_player/parts/watch_together.dart +++ b/lib/screens/video_player/parts/watch_together.dart @@ -172,10 +172,14 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState { return true; } + // fetchItem populates mediaVersions, so the saved preference resolves to + // a verified index/id here rather than a raw stored index. + final savedVersion = await resolveSavedMediaVersionFor(metadata); final handled = await _reloadMediaInPlace( metadata: metadata, - selectedMediaIndex: await savedMediaVersionIndexFor(metadata) ?? 0, - selectedMediaSourceId: null, + selectedMediaIndex: savedVersion?.index ?? 0, + selectedMediaSourceId: savedVersion?.sourceId, + preferredVersionSignature: savedVersion?.signature, qualityPreset: _selectedQualityPreset, preserveCurrentTrackSelection: false, useCurrentAudioStreamSelection: false, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5d1b3415..9b2befc1 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -49,6 +49,7 @@ import '../services/apple_tv_remote_touch_service.dart'; import '../services/media_controls_manager.dart'; import '../services/playback_initialization_service.dart'; import '../services/playback_context.dart'; +import '../services/local_playback_history.dart'; import '../services/playback_session.dart'; import '../services/playback_progress_tracker.dart'; import '../services/playback_source_resolver.dart'; @@ -196,6 +197,12 @@ class VideoPlayerScreen extends StatefulWidget { final SubtitleTrack? preferredSecondarySubtitleTrack; final int selectedMediaIndex; final String? selectedMediaSourceId; + + /// Version signature of a saved preference backing [selectedMediaIndex] + /// when that index is unverified (see + /// [PlaybackInitializationOptions.preferredVersionSignature]). Null for + /// explicit user selections. + final String? preferredVersionSignature; final bool isOffline; /// Quality preset override for this playback. When `null`, the screen uses @@ -221,6 +228,7 @@ class VideoPlayerScreen extends StatefulWidget { this.preferredSecondarySubtitleTrack, this.selectedMediaIndex = 0, this.selectedMediaSourceId, + this.preferredVersionSignature, this.isOffline = false, this.selectedQualityPreset, this.selectedAudioStreamId, @@ -455,6 +463,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin _requestedMediaSourceId = session.mediaSourceId; _selectedQualityPreset = session.qualityPreset; _selectedAudioStreamId = session.audioStreamId; + // Every successful open passes through here (never live TV), making it + // the chokepoint for the local last-played history. Offline plays are + // excluded — like version prefs, the history describes online intent. + if (!session.isOffline) { + unawaited(LocalPlaybackHistory.recordPlayback(session.metadata)); + } } ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time); @@ -697,6 +711,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin metadata: _currentMetadata, selectedMediaIndex: _effectiveSelectedMediaIndex, selectedMediaSourceId: _requestedMediaSourceId, + preferredVersionSignature: widget.preferredVersionSignature, offlineLibraryMode: false, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 93bc030e..563583b9 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -11,6 +11,7 @@ import '../utils/app_logger.dart'; import '../utils/external_ids.dart'; import '../utils/global_key_utils.dart'; import '../utils/search_relevance.dart'; +import 'local_playback_history.dart'; import 'multi_server_manager.dart'; typedef OnDeckAggregationResult = ({ @@ -193,7 +194,17 @@ class DataAggregationService { } await Future.wait(identityKeyLoads); - final seenKeys = {}; + // Group duplicates instead of greedily dropping them: the first item to + // claim an identity key anchors the group and holds its shelf slot; + // later items sharing a claimed key join as members without claiming + // their own keys (same transitive semantics as the old drop). Each slot + // then shows the member the user most recently played on this device — + // servers sync watch state across guid-linked siblings, so their + // lastViewedAt ties and can't tell the 4K copy from the 1080p one + // (#1492). Without local history the anchor (recency order) stands. + final keyToGroup = {}; + final groups = >[]; + final groupSlots = {}; final result = []; for (var i = 0; i < items.length; i++) { final item = items[i]; @@ -208,15 +219,57 @@ class DataAggregationService { continue; } - if (identityKeys.any(seenKeys.contains)) continue; + var joined = false; + for (final key in identityKeys) { + final groupIndex = keyToGroup[key]; + if (groupIndex != null) { + groups[groupIndex].add(item); + joined = true; + break; + } + } + if (joined) continue; - seenKeys.addAll(identityKeys); + final groupIndex = groups.length; + groups.add([item]); + for (final key in identityKeys) { + keyToGroup[key] = groupIndex; + } + groupSlots[result.length] = groupIndex; result.add(item); } + if (groupSlots.isEmpty) return result; + final lastPlayed = await LocalPlaybackHistory.snapshot(); + for (final slot in groupSlots.entries) { + final members = groups[slot.value]; + if (members.length > 1) { + result[slot.key] = _preferLocallyLastPlayed(members, lastPlayed); + } + } return result; } + /// The duplicate-group member most recently played on this device (by item + /// or series key), or the anchor — `members.first`, the group's most recent + /// item by [MediaItem.recencySortKey] — when the local history has nothing + /// newer to say. + MediaItem _preferLocallyLastPlayed(List members, Map lastPlayed) { + var winner = members.first; + var winnerLastPlayedAt = 0; + for (final member in members) { + final itemTs = lastPlayed[member.globalKey] ?? 0; + final seriesKey = member.seriesGlobalKey; + final seriesTs = seriesKey != null ? (lastPlayed[seriesKey] ?? 0) : 0; + final lastPlayedAt = itemTs > seriesTs ? itemTs : seriesTs; + if (lastPlayedAt > winnerLastPlayedAt) { + winner = member; + winnerLastPlayedAt = lastPlayedAt; + } + } + return winner; + } + String? _continueWatchingTitleBucket(MediaItem item) { final scope = _continueWatchingIdentityScope(item); if (scope == null) return null; diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 1b38821f..44fe0bfa 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -40,6 +40,7 @@ import '../models/media_provider_info.dart'; import '../models/media_subscription.dart'; import '../media/media_source_info.dart'; import '../media/media_sort.dart'; +import '../media/media_version.dart'; import '../utils/app_logger.dart'; import '../utils/device_identity.dart'; import '../utils/failover_http_client.dart'; diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index e47b52d6..91a24774 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -2,7 +2,12 @@ part of '../../jellyfin_client.dart'; mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { JellyfinConnection get connection; - Future fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId}); + Future fetchPlaybackBundle( + String itemId, { + int sourceIndex = 0, + String? sourceId, + String? preferredSignature, + }); String buildDirectStreamUrl( String itemId, { String? container, diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index bab1cb6c..43aaf0e7 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -139,6 +139,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { metadata.id, sourceIndex: options.selectedMediaIndex, sourceId: options.selectedMediaSourceId, + preferredSignature: options.preferredVersionSignature, ); if (bundle == null) { throw PlaybackException('Item ${metadata.id} returned no MediaSources'); @@ -362,7 +363,12 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { /// [sourceId] wins when present because Jellyfin plugins may reorder merged /// `MediaSources` between requests. [sourceIndex] is clamped to the valid /// range as a fallback to mirror Plex's `parseVideoPlaybackDataFromJson`. - Future fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId}) async { + Future fetchPlaybackBundle( + String itemId, { + int sourceIndex = 0, + String? sourceId, + String? preferredSignature, + }) async { final item = await fetchItem(itemId); final raw = item?.raw; if (raw is! Map) return null; @@ -371,9 +377,20 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { final availableVersions = jellyfinSourcesToVersions(sources); var index = sourceIndex; final requestedSourceId = sourceId?.trim(); + var resolvedBySourceId = false; if (requestedSourceId != null && requestedSourceId.isNotEmpty) { final byId = sources.indexWhere((source) => source is Map && source['Id'] == requestedSourceId); - if (byId >= 0) index = byId; + if (byId >= 0) { + index = byId; + resolvedBySourceId = true; + } + } + // Saved-preference signature: only meaningful when the id didn't pin a + // source (Resume rows omit MediaSources, so launch passes a signature and + // a stored index that may not fit this item's source ordering). + if (!resolvedBySourceId && preferredSignature != null && preferredSignature.isNotEmpty) { + final bySignature = MediaVersion.findMatchingIndex(availableVersions, {preferredSignature}); + if (bySignature != null) index = bySignature; } if (index < 0 || index >= sources.length) index = 0; final source = sources[index]; diff --git a/lib/services/local_playback_history.dart b/lib/services/local_playback_history.dart new file mode 100644 index 00000000..d2e5b37b --- /dev/null +++ b/lib/services/local_playback_history.dart @@ -0,0 +1,69 @@ +import 'package:flutter/foundation.dart'; + +import '../media/media_item.dart'; +import '../utils/app_logger.dart'; +import 'settings_service.dart'; + +/// Device-local record of when items were last played, keyed by item +/// [MediaItem.globalKey] and, for episodes/seasons, also by +/// [MediaItem.seriesGlobalKey] (#1492). +/// +/// Servers have no per-version/per-sibling "last played" signal (a Plex +/// timeline report carries no media id, and guid-linked duplicates share +/// synced watch state), so this local history is what lets the Continue +/// Watching dedup keep the sibling the user actually played. +class LocalPlaybackHistory { + LocalPlaybackHistory._(); + + static const _maxEntries = 400; + + /// Repeat writes for the same item within this window are skipped — + /// in-place reloads (seek transcode restarts, track switches) re-commit + /// the same session and don't need to re-serialize the map each time. + static const _rewriteIntervalMs = 60 * 1000; + + static String? _lastRecordedKey; + static int _lastRecordedAtMs = 0; + + /// Test-only: clear the same-item rewrite suppression. + @visibleForTesting + static void resetForTesting() { + _lastRecordedKey = null; + _lastRecordedAtMs = 0; + } + + /// Record that [item] just started playing. Best-effort: playback must + /// never fail on a preferences error. + static Future recordPlayback(MediaItem item) async { + final now = DateTime.now().millisecondsSinceEpoch; + final key = item.globalKey; + if (key == _lastRecordedKey && now - _lastRecordedAtMs < _rewriteIntervalMs) return; + try { + final settings = await SettingsService.getInstance(); + final updated = {...settings.read(SettingsService.localLastPlayedAt), key: now}; + final seriesKey = item.seriesGlobalKey; + if (seriesKey != null) updated[seriesKey] = now; + await settings.write(SettingsService.localLastPlayedAt, _prune(updated)); + _lastRecordedKey = key; + _lastRecordedAtMs = now; + } catch (e) { + appLogger.w('Failed to record local playback history for $key', error: e); + } + } + + /// One read of the full history for a dedup pass. Empty on any error. + static Future> snapshot() async { + try { + final settings = await SettingsService.getInstance(); + return settings.read(SettingsService.localLastPlayedAt); + } catch (_) { + return const {}; + } + } + + static Map _prune(Map history) { + if (history.length <= _maxEntries) return history; + final entries = history.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + return Map.fromEntries(entries.take(_maxEntries)); + } +} diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 4b4374ac..8da585b0 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -153,6 +153,7 @@ class PlaybackInitializationService { required MediaItem metadata, required int selectedMediaIndex, String? selectedMediaSourceId, + String? preferredVersionSignature, bool preferOffline = false, TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original, int? selectedAudioStreamId, @@ -197,6 +198,7 @@ class PlaybackInitializationService { metadata: metadata, selectedMediaIndex: selectedMediaIndex, selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, qualityPreset: qualityPreset, selectedAudioStreamId: selectedAudioStreamId, sessionIdentifier: sessionIdentifier, diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index 840176f5..1d5c6892 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -17,6 +17,13 @@ class PlaybackInitializationOptions { /// versions can reorder between item fetches, so this wins over index there. final String? selectedMediaSourceId; + /// Version signature ("res:codec:container") of a saved preference whose + /// [selectedMediaIndex] is a guess (stored index, or resolved on another + /// episode's version list). Backends re-match it against the authoritative + /// list. Never set alongside an explicit user selection — the priority is + /// sourceId > signature > index > backend fallback. + final String? preferredVersionSignature; + /// Transcode preset. `original` means direct-play; anything else asks the /// server to transcode when supported. final TranscodeQualityPreset qualityPreset; @@ -36,6 +43,7 @@ class PlaybackInitializationOptions { required this.metadata, required this.selectedMediaIndex, this.selectedMediaSourceId, + this.preferredVersionSignature, this.qualityPreset = TranscodeQualityPreset.original, this.selectedAudioStreamId, this.sessionIdentifier, diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index ac22a5e8..ab0d26ef 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -21,6 +21,7 @@ class PlaybackSourceResolver { required MediaItem metadata, required int selectedMediaIndex, String? selectedMediaSourceId, + String? preferredVersionSignature, required bool offlineLibraryMode, required TranscodeQualityPreset qualityPreset, int? selectedAudioStreamId, @@ -34,6 +35,7 @@ class PlaybackSourceResolver { metadata: metadata, selectedMediaIndex: selectedMediaIndex, selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal), qualityPreset: qualityPreset, selectedAudioStreamId: selectedAudioStreamId, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8ddde4ec..9e12421d 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1512,12 +1512,19 @@ class PlexClient /// Parse video playback data from raw metadata JSON (no network call). /// Used by [getVideoPlaybackData] to avoid redundant fetches when the /// response is already available. - PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map? metadataJson, {int mediaIndex = 0}) { + PlexVideoPlaybackData parseVideoPlaybackDataFromJson( + Map? metadataJson, { + int mediaIndex = 0, + String? selectedMediaSourceId, + String? preferredVersionSignature, + }) { return parsePlexVideoPlaybackDataFromJson( metadataJson, baseUrl: config.baseUrl, token: config.token, mediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, onVersionFallback: (requested, fallback) { appLogger.w('Version $requested inaccessible/missing — falling back to version $fallback'); }, @@ -1527,7 +1534,12 @@ class PlexClient /// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call. /// This is the primary method for playback initialization. /// Uses cache for offline mode support and network fallback. - Future getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async { + Future getVideoPlaybackData( + String ratingKey, { + int mediaIndex = 0, + String? selectedMediaSourceId, + String? preferredVersionSignature, + }) async { Map? data; try { data = await fetchWithCacheFallback>( @@ -1545,7 +1557,12 @@ class PlexClient // Gracefully degrade: return empty playback data on total failure } final metadataJson = _getFirstMetadataJsonFromData(data); - return parseVideoPlaybackDataFromJson(metadataJson, mediaIndex: mediaIndex); + return parseVideoPlaybackDataFromJson( + metadataJson, + mediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, + ); } /// Get file information for a media item. @@ -3386,7 +3403,12 @@ class PlexClient @override Future getPlaybackInitialization(PlaybackInitializationOptions options) async { try { - final data = await getVideoPlaybackData(options.metadata.id, mediaIndex: options.selectedMediaIndex); + final data = await getVideoPlaybackData( + options.metadata.id, + mediaIndex: options.selectedMediaIndex, + selectedMediaSourceId: options.selectedMediaSourceId, + preferredVersionSignature: options.preferredVersionSignature, + ); if (!data.hasValidVideoUrl) { throw PlaybackException(t.messages.fileInfoNotAvailable); diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart index 527a7d1c..d5c9569e 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -61,6 +61,8 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( required String baseUrl, required String? token, int mediaIndex = 0, + String? selectedMediaSourceId, + String? preferredVersionSignature, void Function(int requestedIndex, int fallbackIndex)? onVersionFallback, }) { String? videoUrl; @@ -77,6 +79,24 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( .map((media) => PlexMappers.mediaVersionFromJson(Map.from(media))) .toList(); + // Re-resolve version evidence against this (authoritative) Media list: + // stable id first, then signature. The positional index is the last + // resort — and all an explicit user pick carries besides its id, so a + // saved-preference signature can never override one. + final requestedSourceId = selectedMediaSourceId?.trim(); + var resolvedBySourceId = false; + if (requestedSourceId != null && requestedSourceId.isNotEmpty) { + final byId = availableVersions.indexWhere((v) => v.id == requestedSourceId); + if (byId >= 0) { + mediaIndex = byId; + resolvedBySourceId = true; + } + } + if (!resolvedBySourceId && preferredVersionSignature != null && preferredVersionSignature.isNotEmpty) { + final bySignature = MediaVersion.findMatchingIndex(availableVersions, {preferredVersionSignature}); + if (bySignature != null) mediaIndex = bySignature; + } + if (mediaIndex < 0 || mediaIndex >= mediaList.length) { mediaIndex = 0; } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 0997e21b..ce704520 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import '../media/ids.dart'; +import '../media/media_version_preference.dart'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; @@ -514,9 +515,20 @@ class SettingsService extends BaseSharedPreferencesService { encode: (v) => json.encode(v.map((k, hk) => MapEntry(k, SettingsService.serializeHotKey(hk)))), decode: _decodeKeyboardHotkeys, ); - static final mediaVersionPreferences = JsonPref>( + static final mediaVersionPreferences = JsonPref>( 'media_version_preferences', defaultValue: const {}, + encode: (v) => json.encode(v.map((k, pref) => MapEntry(k, pref.toJson()))), + // Legacy values were bare ints; MediaVersionPreference.fromJson accepts both. + decode: (raw) => (raw as Map).map((k, v) => MapEntry(k, MediaVersionPreference.fromJson(v))), + ); + + /// Local record of when items were last played on this device + /// (item/show globalKey → epoch ms). Written by LocalPlaybackHistory; used + /// to pick the last-played sibling in the Continue Watching dedup (#1492). + static final localLastPlayedAt = JsonPref>( + 'local_last_played_at', + defaultValue: const {}, encode: json.encode, decode: (raw) => (raw as Map).map((k, v) => MapEntry(k, v as int)), ); @@ -857,6 +869,7 @@ class SettingsService extends BaseSharedPreferencesService { libraryDensity, episodePosterMode, mediaVersionPreferences, + localLastPlayedAt, appLocale, customDownloadPath, videoPlayerNavigationEnabled, diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 6ff823b5..6292ec2b 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../media/media_item.dart'; +import '../media/media_version.dart'; +import '../media/media_version_preference.dart'; import '../mpv/mpv.dart'; import '../models/transcode_quality_preset.dart'; import '../providers/download_provider.dart'; @@ -14,9 +16,11 @@ import '../providers/watch_state_store.dart'; import '../watch_together/providers/watch_together_provider.dart'; import '../screens/video_player_screen.dart'; import '../services/external_player_service.dart'; +import '../services/local_playback_history.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import 'app_logger.dart'; +import 'global_key_utils.dart'; import 'platform_detector.dart'; const String kVideoPlayerRouteName = '/video_player'; @@ -89,28 +93,82 @@ class WatchTogetherPlaybackNavigationException implements Exception { } /// Series (keyed by grandparent) or standalone-item key under -/// [SettingsService.mediaVersionPreferences]. -String _mediaVersionPreferenceKey(MediaItem metadata) => metadata.grandparentId ?? metadata.id; +/// [SettingsService.mediaVersionPreferences], scoped by server — raw Plex +/// rating keys are small integers that can collide across servers. +String _mediaVersionPreferenceKey(MediaItem metadata) { + final serverId = serverIdOrNull(metadata.serverId); + final id = metadata.grandparentId ?? metadata.id; + return serverId != null ? buildGlobalKey(serverId, id) : id; +} -/// Saved media-version preference for [metadata], or null when none is -/// stored. Shared by launch navigation and in-player version switching so -/// reads and writes can't drift onto different keys. -Future savedMediaVersionIndexFor(MediaItem metadata) async { +/// Key entries were stored under before server scoping. Reads fall back to +/// it; writes migrate it to the scoped key. +String _legacyMediaVersionPreferenceKey(MediaItem metadata) => metadata.grandparentId ?? metadata.id; + +/// Entry cap for [SettingsService.mediaVersionPreferences]; oldest entries +/// (by write time, legacy entries first) are evicted past it. +const _maxMediaVersionPreferences = 500; + +/// Saved media-version preference for [metadata]'s series/movie, or null when +/// none is stored. Shared by launch navigation and in-player version +/// switching so reads and writes can't drift onto different keys. +Future savedMediaVersionPreferenceFor(MediaItem metadata) async { try { final settingsService = await SettingsService.getInstance(); - return settingsService.read(SettingsService.mediaVersionPreferences)[_mediaVersionPreferenceKey(metadata)]; + final prefs = settingsService.read(SettingsService.mediaVersionPreferences); + return prefs[_mediaVersionPreferenceKey(metadata)] ?? prefs[_legacyMediaVersionPreferenceKey(metadata)]; } catch (_) { return null; } } -/// Persist [index] as the preferred media version for [metadata]'s series/movie. -Future saveMediaVersionIndexFor(MediaItem metadata, int index) async { +/// Persist the version at [index] in [versions] as the preferred media +/// version for [metadata]'s series/movie. Callers are explicit-selection +/// sites only — plain plays and backend fallbacks must not write, so a +/// server-side clamp can't silently overwrite the user's choice. +Future saveMediaVersionPreferenceFor( + MediaItem metadata, { + required int index, + required List versions, +}) async { final settingsService = await SettingsService.getInstance(); - await settingsService.write(SettingsService.mediaVersionPreferences, { - ...settingsService.read(SettingsService.mediaVersionPreferences), - _mediaVersionPreferenceKey(metadata): index, - }); + final pref = index >= 0 && index < versions.length + ? MediaVersionPreference.forVersion(versions[index], index) + : MediaVersionPreference(index: index, updatedAt: DateTime.now().millisecondsSinceEpoch); + final updated = {...settingsService.read(SettingsService.mediaVersionPreferences)} + ..remove(_legacyMediaVersionPreferenceKey(metadata)) + ..[_mediaVersionPreferenceKey(metadata)] = pref; + await settingsService.write(SettingsService.mediaVersionPreferences, _pruneMediaVersionPreferences(updated)); +} + +Map _pruneMediaVersionPreferences(Map prefs) { + if (prefs.length <= _maxMediaVersionPreferences) return prefs; + final entries = prefs.entries.toList()..sort((a, b) => (b.value.updatedAt ?? 0).compareTo(a.value.updatedAt ?? 0)); + return Map.fromEntries(entries.take(_maxMediaVersionPreferences)); +} + +/// A saved preference resolved for launch: the index to request plus the +/// id/signature evidence for re-resolving it against the authoritative +/// version list during playback initialization. +typedef ResolvedMediaVersionPreference = ({int index, String? sourceId, String? signature}); + +/// Resolve the saved preference for [metadata] against its version list. +/// +/// When [MediaItem.mediaVersions] is populated (Plex hub/detail fetches) the +/// index is verified and the matched version's real id is returned. When it +/// isn't (Jellyfin resume rows omit `MediaSources`), the stored index and +/// signature pass through with a null sourceId — an unverified id from a +/// sibling episode would be meaningless downstream, while a signature is +/// safely re-matched there. +Future resolveSavedMediaVersionFor(MediaItem metadata) async { + final pref = await savedMediaVersionPreferenceFor(metadata); + if (pref == null) return null; + final versions = metadata.mediaVersions ?? const []; + if (versions.isEmpty) return (index: pref.index, sourceId: null, signature: pref.signature); + final index = pref.resolveIndex(versions); + if (index == null) return null; + final version = versions[index]; + return (index: index, sourceId: version.id.isEmpty ? null : version.id, signature: version.signature); } /// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash. @@ -177,8 +235,18 @@ Future navigateToVideoPlayer( } } - final mediaIndex = selectedMediaIndex ?? downloadedMediaIndex ?? await savedMediaVersionIndexFor(metadata) ?? 0; - final mediaSourceId = selectedMediaSourceId ?? downloadedMediaSourceId; + // Saved preferences only apply when nothing explicit is in play — an + // explicit caller selection or a downloaded version must never be + // second-guessed by a remembered choice. + ResolvedMediaVersionPreference? savedVersion; + if (selectedMediaIndex == null && + selectedMediaSourceId == null && + downloadedMediaIndex == null && + downloadedMediaSourceId == null) { + savedVersion = await resolveSavedMediaVersionFor(metadata); + } + final mediaIndex = selectedMediaIndex ?? downloadedMediaIndex ?? savedVersion?.index ?? 0; + final mediaSourceId = selectedMediaSourceId ?? downloadedMediaSourceId ?? savedVersion?.sourceId; var markedInFlight = false; if (!usePushReplacement) { @@ -235,7 +303,12 @@ Future navigateToVideoPlayer( ); } - if (launched) return null; + if (launched) { + // External playback never reaches the in-player session commit, so + // record the local last-played history here. + if (!isOffline) unawaited(LocalPlaybackHistory.recordPlayback(metadata)); + return null; + } } } catch (e) { appLogger.w('External player launch failed, falling back to built-in player', error: e); @@ -260,6 +333,7 @@ Future navigateToVideoPlayer( preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: mediaIndex, selectedMediaSourceId: mediaSourceId, + preferredVersionSignature: savedVersion?.signature, selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, ), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 46f8895e..0e3701d7 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -902,6 +902,13 @@ class MediaContextMenuState extends State { selectedQuality = picked; } + // Remember the pick so Continue Watching / plain Play resume this version + // (#1492) — same store the in-player version switch writes. + if (versions.length > 1) { + await saveMediaVersionPreferenceFor(item, index: selectedVersionIndex, versions: versions); + if (!context.mounted) return false; + } + await navigateToVideoPlayer( context, metadata: item, diff --git a/test/media/media_version_preference_test.dart b/test/media/media_version_preference_test.dart new file mode 100644 index 00000000..96b176b7 --- /dev/null +++ b/test/media/media_version_preference_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_version.dart'; +import 'package:plezy/media/media_version_preference.dart'; + +void main() { + const versions = [ + MediaVersion(id: '101', videoResolution: '1080', videoCodec: 'h264', container: 'mkv'), + MediaVersion(id: '102', videoResolution: '4k', videoCodec: 'hevc', container: 'mkv'), + ]; + + group('MediaVersionPreference.fromJson', () { + test('decodes legacy bare int as index-only record', () { + final pref = MediaVersionPreference.fromJson(1); + expect(pref.index, 1); + expect(pref.versionId, isNull); + expect(pref.signature, isNull); + expect(pref.updatedAt, isNull); + }); + + test('round-trips the record form', () { + final pref = MediaVersionPreference.forVersion(versions[1], 1); + final decoded = MediaVersionPreference.fromJson(pref.toJson()); + expect(decoded.versionId, '102'); + expect(decoded.signature, '4k:hevc:mkv'); + expect(decoded.index, 1); + expect(decoded.updatedAt, pref.updatedAt); + }); + }); + + group('MediaVersionPreference.resolveIndex', () { + test('exact version id wins over stored index', () { + const pref = MediaVersionPreference(versionId: '102', signature: '4k:hevc:mkv', index: 0); + expect(pref.resolveIndex(versions), 1); + }); + + test('signature matches when the id is from a sibling episode', () { + const pref = MediaVersionPreference(versionId: '999', signature: '4k:hevc:mkv', index: 0); + expect(pref.resolveIndex(versions), 1); + }); + + test('signature matches by resolution when codec/container differ', () { + const pref = MediaVersionPreference(versionId: '999', signature: '4k:av1:mp4', index: 0); + expect(pref.resolveIndex(versions), 1); + }); + + test('falls back to stored index when id and signature miss', () { + const pref = MediaVersionPreference(versionId: '999', signature: '720:vp9:webm', index: 1); + expect(pref.resolveIndex(versions), 1); + }); + + test('returns null for out-of-range index with no match', () { + const pref = MediaVersionPreference(index: 5); + expect(pref.resolveIndex(versions), isNull); + expect(pref.resolveIndex(const []), isNull); + }); + }); +} diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index e5c20fb3..64211858 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -18,6 +18,9 @@ import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/services/settings_service.dart'; + +import '../test_helpers/prefs.dart'; JellyfinConnection _conn() => JellyfinConnection( id: 'srv-1/user-1', @@ -72,6 +75,8 @@ void main() { late DataAggregationService service; setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); manager = MultiServerManager(); @@ -366,6 +371,170 @@ void main() { expect(result.succeededServerIds, {'plex-1'}); }); + test('getOnDeckFromAllServers prefers the locally last-played duplicate sibling', () async { + // Two libraries carry the same show (1080p + 4K, matched by tvdb id); + // the server syncs watch state so lastViewedAt favours neither + // reliably. The locally recorded play must decide the surviving card — + // and it must keep the winner in the group's original shelf slot, + // ahead of the unrelated movie sorted between the two episodes (#1492). + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: ServerId('plex-1'), + serverName: 'Plex', + httpClient: MockClient((req) async { + if (req.url.path == '/hubs') { + return _json({ + 'MediaContainer': { + 'Hub': [ + { + 'key': '/hubs/home/continueWatching', + 'title': 'Continue Watching', + 'type': 'mixed', + 'hubIdentifier': 'home.continue', + 'size': 3, + 'Metadata': [ + { + 'ratingKey': 'hd-episode', + 'type': 'episode', + 'title': 'Episode 1', + 'grandparentRatingKey': 'hd-show', + 'grandparentTitle': 'Shared Show', + 'guid': 'plex://episode/shared-episode-hd', + 'lastViewedAt': 200, + 'librarySectionID': 1, + }, + {'ratingKey': 'movie-between', 'type': 'movie', 'title': 'Unrelated Movie', 'lastViewedAt': 150}, + { + 'ratingKey': 'uhd-episode', + 'type': 'episode', + 'title': 'Episode 1', + 'grandparentRatingKey': 'uhd-show', + 'grandparentTitle': 'Shared Show', + 'guid': 'plex://episode/shared-episode-uhd', + 'lastViewedAt': 100, + 'librarySectionID': 2, + }, + ], + }, + ], + }, + }); + } + if (req.url.path == '/library/metadata/hd-show' || req.url.path == '/library/metadata/uhd-show') { + return _json({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': req.url.pathSegments.last, + 'type': 'show', + 'title': 'Shared Show', + 'Guid': [ + {'id': 'tvdb://12345'}, + ], + }, + ], + }, + }); + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(client.close); + manager.debugRegisterClientForTesting(client); + + // The user last played something in the 4K library's show tree. + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.localLastPlayedAt, {'plex-1:uhd-show': 999999}); + + final result = await service.getOnDeckFromAllServers(limit: 10); + + // uhd-episode wins the duplicate group and takes the group's slot + // (before the movie); without local history hd-episode (newest + // lastViewedAt) would have survived. + expect(result.items.map((item) => item.id), ['uhd-episode', 'movie-between']); + }); + + test('getOnDeckFromAllServers prefers a duplicate recorded by item key', () async { + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: ServerId('plex-1'), + serverName: 'Plex', + httpClient: MockClient((req) async { + if (req.url.path == '/hubs') { + return _json({ + 'MediaContainer': { + 'Hub': [ + { + 'key': '/hubs/home/continueWatching', + 'title': 'Continue Watching', + 'type': 'mixed', + 'hubIdentifier': 'home.continue', + 'size': 2, + 'Metadata': [ + { + 'ratingKey': 'movie-hd', + 'type': 'movie', + 'title': 'Shared Movie', + 'guid': 'plex://movie/shared-movie', + 'lastViewedAt': 200, + 'librarySectionID': 1, + }, + { + 'ratingKey': 'movie-uhd', + 'type': 'movie', + 'title': 'Shared Movie', + 'guid': 'plex://movie/shared-movie', + 'lastViewedAt': 100, + 'librarySectionID': 2, + }, + ], + }, + ], + }, + }); + } + if (req.url.path == '/library/metadata/movie-hd' || req.url.path == '/library/metadata/movie-uhd') { + return _json({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': req.url.pathSegments.last, + 'type': 'movie', + 'title': 'Shared Movie', + 'Guid': [ + {'id': 'tmdb://777'}, + ], + }, + ], + }, + }); + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(client.close); + manager.debugRegisterClientForTesting(client); + + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.localLastPlayedAt, {'plex-1:movie-uhd': 999999}); + + final result = await service.getOnDeckFromAllServers(limit: 10); + + expect(result.items.map((item) => item.id), ['movie-uhd']); + }); + test('getOnDeckFromAllServers keeps duplicate titles without stable ids', () async { final client = PlexClient.forTesting( config: PlexConfig( diff --git a/test/services/jellyfin_playback_bundle_test.dart b/test/services/jellyfin_playback_bundle_test.dart index a0a02b0c..5e5412f2 100644 --- a/test/services/jellyfin_playback_bundle_test.dart +++ b/test/services/jellyfin_playback_bundle_test.dart @@ -168,6 +168,49 @@ void main() { client.close(); }); + test('selects source by preferred signature when no sourceId pins one', () async { + final body = jsonEncode({ + 'Id': 'item-5', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }); + final client = buildClient(body); + + // Grab the real signature of the 4K source, as a saved preference would + // have captured it on a previous play. + final probe = await client.fetchPlaybackBundle('item-5'); + final signature = probe!.availableVersions[1].signature; + + final bundle = await client.fetchPlaybackBundle('item-5', sourceIndex: 0, preferredSignature: signature); + expect(bundle!.selectedSourceId, 'src-4k'); + expect(bundle.selectedSourceIndex, 1); + + // An explicit sourceId still wins over the signature. + final pinned = await client.fetchPlaybackBundle( + 'item-5', + sourceIndex: 0, + sourceId: 'src-1080', + preferredSignature: signature, + ); + expect(pinned!.selectedSourceId, 'src-1080'); + client.close(); + }); + test('chapters defaults to empty list when item has no Chapters field', () async { final body = jsonEncode({ 'Id': 'item-3', diff --git a/test/services/local_playback_history_test.dart b/test/services/local_playback_history_test.dart new file mode 100644 index 00000000..3bc55df3 --- /dev/null +++ b/test/services/local_playback_history_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.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/services/local_playback_history.dart'; +import 'package:plezy/services/settings_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + LocalPlaybackHistory.resetForTesting(); + }); + + test('recordPlayback writes item and series keys for an episode', () async { + final episode = MediaItem( + id: 'ep-1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + serverId: 'srv-1', + grandparentId: 'show-1', + ); + + await LocalPlaybackHistory.recordPlayback(episode); + + final history = await LocalPlaybackHistory.snapshot(); + expect(history.keys, containsAll(['srv-1:ep-1', 'srv-1:show-1'])); + expect(history['srv-1:ep-1'], history['srv-1:show-1']); + }); + + test('recordPlayback writes only the item key for a movie', () async { + final movie = MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'srv-1', + ); + + await LocalPlaybackHistory.recordPlayback(movie); + + final history = await LocalPlaybackHistory.snapshot(); + expect(history.keys, ['srv-1:movie-1']); + }); + + test('repeat writes for the same item within the rewrite window are skipped', () async { + final movie = MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'srv-1', + ); + + await LocalPlaybackHistory.recordPlayback(movie); + final first = (await LocalPlaybackHistory.snapshot())['srv-1:movie-1']; + await LocalPlaybackHistory.recordPlayback(movie); + final second = (await LocalPlaybackHistory.snapshot())['srv-1:movie-1']; + + expect(second, first); + }); + + test('prunes the oldest entries past the cap', () async { + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.localLastPlayedAt, {for (var i = 0; i < 400; i++) 'srv-1:old-$i': i + 1}); + + final movie = MediaItem( + id: 'fresh', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Fresh', + serverId: 'srv-1', + ); + await LocalPlaybackHistory.recordPlayback(movie); + + final history = await LocalPlaybackHistory.snapshot(); + expect(history.length, 400); + expect(history, contains('srv-1:fresh')); + // The oldest entry (value 1) was evicted to make room. + expect(history, isNot(contains('srv-1:old-0'))); + }); +} diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart index 53c1a212..c6f74faf 100644 --- a/test/services/plex_playback_mapper_test.dart +++ b/test/services/plex_playback_mapper_test.dart @@ -118,6 +118,140 @@ void main() { expect(result.availableVersions.single.parts.last.isPlayable, isTrue); }); + test('selects version by media source id over the requested index', () { + final result = parsePlexVideoPlaybackDataFromJson( + { + 'Media': [ + { + 'id': 101, + 'videoResolution': '1080', + 'videoCodec': 'h264', + 'container': 'mkv', + 'Part': [ + {'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1}, + ], + }, + { + 'id': 102, + 'videoResolution': '4k', + 'videoCodec': 'hevc', + 'container': 'mkv', + 'Part': [ + {'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1}, + ], + }, + ], + }, + baseUrl: 'http://plex:32400', + token: 'tok', + mediaIndex: 0, + selectedMediaSourceId: '102', + ); + + expect(result.selectedMediaIndex, 1); + expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok'); + }); + + test('selects version by preferred signature when the id misses', () { + final result = parsePlexVideoPlaybackDataFromJson( + { + 'Media': [ + { + 'id': 201, + 'videoResolution': '1080', + 'videoCodec': 'h264', + 'container': 'mkv', + 'Part': [ + {'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1}, + ], + }, + { + 'id': 202, + 'videoResolution': '4k', + 'videoCodec': 'hevc', + 'container': 'mkv', + 'Part': [ + {'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1}, + ], + }, + ], + }, + baseUrl: 'http://plex:32400', + token: 'tok', + mediaIndex: 0, + // Sibling episode's id — meaningless here; the signature must decide. + selectedMediaSourceId: '999', + preferredVersionSignature: '4k:hevc:mkv', + ); + + expect(result.selectedMediaIndex, 1); + }); + + test('keeps the requested index when id and signature both miss', () { + final result = parsePlexVideoPlaybackDataFromJson( + { + 'Media': [ + { + 'id': 301, + 'videoResolution': '1080', + 'Part': [ + {'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1}, + ], + }, + { + 'id': 302, + 'videoResolution': '720', + 'Part': [ + {'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1}, + ], + }, + ], + }, + baseUrl: 'http://plex:32400', + token: 'tok', + mediaIndex: 1, + preferredVersionSignature: '4k:av1:mp4', + ); + + expect(result.selectedMediaIndex, 1); + }); + + test('signature-resolved version still falls back when unplayable', () { + late (int, int) fallback; + final result = parsePlexVideoPlaybackDataFromJson( + { + 'Media': [ + { + 'id': 401, + 'videoResolution': '1080', + 'videoCodec': 'h264', + 'container': 'mkv', + 'Part': [ + {'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1}, + ], + }, + { + 'id': 402, + 'videoResolution': '4k', + 'videoCodec': 'hevc', + 'container': 'mkv', + 'Part': [ + {'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 0, 'exists': 0}, + ], + }, + ], + }, + baseUrl: 'http://plex:32400', + token: 'tok', + mediaIndex: 0, + preferredVersionSignature: '4k:hevc:mkv', + onVersionFallback: (requested, selected) => fallback = (requested, selected), + ); + + expect(fallback, (1, 0)); + expect(result.selectedMediaIndex, 0); + }); + test('maps server display criteria from selected video stream', () { final result = parsePlexVideoPlaybackDataFromJson( { diff --git a/test/utils/video_player_navigation_test.dart b/test/utils/video_player_navigation_test.dart index 18da6d46..fcbdb3a4 100644 --- a/test/utils/video_player_navigation_test.dart +++ b/test/utils/video_player_navigation_test.dart @@ -1,9 +1,15 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.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_version.dart'; +import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/video_player_navigation.dart'; +import '../test_helpers/prefs.dart'; + void main() { test('in-flight video player navigation rejects duplicate requests', () { final guard = VideoPlayerNavigationInFlightGuard(); @@ -35,4 +41,105 @@ void main() { isTrue, ); }); + + group('media version preference persistence', () { + const versions = [ + MediaVersion(id: '101', videoResolution: '1080', videoCodec: 'h264', container: 'mkv'), + MediaVersion(id: '102', videoResolution: '4k', videoCodec: 'hevc', container: 'mkv'), + ]; + + final episode = MediaItem( + id: 'ep-1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + serverId: 'srv-1', + grandparentId: 'show-1', + mediaVersions: versions, + ); + + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + }); + + test('save writes under the server-scoped series key', () async { + await saveMediaVersionPreferenceFor(episode, index: 1, versions: versions); + + final settings = await SettingsService.getInstance(); + final prefs = settings.read(SettingsService.mediaVersionPreferences); + expect(prefs.keys, ['srv-1:show-1']); + expect(prefs['srv-1:show-1']!.versionId, '102'); + expect(prefs['srv-1:show-1']!.signature, '4k:hevc:mkv'); + expect(prefs['srv-1:show-1']!.index, 1); + }); + + test('reads legacy unscoped-key int entries and migrates them on write', () async { + resetSharedPreferencesForTest( + initialAsync: { + 'media_version_preferences': jsonEncode({'show-1': 1}), + }, + ); + SettingsService.resetForTesting(); + + final saved = await savedMediaVersionPreferenceFor(episode); + expect(saved, isNotNull); + expect(saved!.index, 1); + expect(saved.versionId, isNull); + + await saveMediaVersionPreferenceFor(episode, index: 0, versions: versions); + final settings = await SettingsService.getInstance(); + final prefs = settings.read(SettingsService.mediaVersionPreferences); + expect(prefs.keys, ['srv-1:show-1']); + expect(prefs['srv-1:show-1']!.versionId, '101'); + }); + + test('resolveSavedMediaVersionFor verifies against populated mediaVersions', () async { + // Stored index points at 0, but the id pins version 102 → index 1. + resetSharedPreferencesForTest( + initialAsync: { + 'media_version_preferences': jsonEncode({ + 'srv-1:show-1': {'id': '102', 'sig': '4k:hevc:mkv', 'idx': 0}, + }), + }, + ); + SettingsService.resetForTesting(); + + final resolved = await resolveSavedMediaVersionFor(episode); + expect(resolved, isNotNull); + expect(resolved!.index, 1); + expect(resolved.sourceId, '102'); + expect(resolved.signature, '4k:hevc:mkv'); + }); + + test('resolveSavedMediaVersionFor passes stored index/signature through without versions', () async { + resetSharedPreferencesForTest( + initialAsync: { + 'media_version_preferences': jsonEncode({ + 'srv-1:show-1': {'id': '102', 'sig': '4k:hevc:mkv', 'idx': 1}, + }), + }, + ); + SettingsService.resetForTesting(); + + final bare = MediaItem( + id: 'ep-2', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode 2', + serverId: 'srv-1', + grandparentId: 'show-1', + ); + final resolved = await resolveSavedMediaVersionFor(bare); + expect(resolved, isNotNull); + expect(resolved!.index, 1); + // An id from another item must not be forwarded as an explicit pick. + expect(resolved.sourceId, isNull); + expect(resolved.signature, '4k:hevc:mkv'); + }); + + test('resolveSavedMediaVersionFor returns null when nothing is stored', () async { + expect(await resolveSavedMediaVersionFor(episode), isNull); + }); + }); }