diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index c6019532..9166c620 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -23,6 +23,7 @@ import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; import '../utils/app_logger.dart'; import '../utils/deletion_notifier.dart'; +import '../utils/downloaded_version_match.dart'; import '../media/episode_collection.dart'; import '../utils/global_key_utils.dart'; import '../utils/watch_state_notifier.dart'; @@ -784,6 +785,19 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Check if an item is currently being queued (building download queue) bool isQueueing(String globalKey) => _queueing.contains(globalKey); + /// Get the completed download record for an item, or null when the item + /// isn't fully downloaded or isn't owned by the active profile. Callers use + /// the row's mediaIndex/mediaSourceId to target the version actually on + /// disk instead of assuming the server default. + Future getCompletedDownload(String globalKey) async { + if (!_ownsDownloadKey(globalKey)) return null; + final downloadedItem = await _downloadManager.getDownloadedMedia(globalKey); + if (downloadedItem == null || downloadedItem.status != DownloadStatus.completed.index) { + return null; + } + return downloadedItem; + } + /// Get the local video file path for a downloaded item /// Returns null if not downloaded or file doesn't exist Future getVideoFilePath(String globalKey, {int? mediaIndex, String? mediaSourceId}) async { @@ -802,22 +816,15 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.w('Download not complete. Status: ${downloadedItem.status}'); return null; } - final expectedSourceId = mediaSourceId?.trim(); - final downloadedSourceId = downloadedItem.mediaSourceId; - final comparedBySourceId = - expectedSourceId != null && - expectedSourceId.isNotEmpty && - downloadedSourceId != null && - downloadedSourceId.isNotEmpty; - if (comparedBySourceId && expectedSourceId != downloadedSourceId) { + if (!downloadedVersionMatches( + downloadedItem, + requestedMediaIndex: mediaIndex, + requestedMediaSourceId: mediaSourceId, + )) { appLogger.w( - 'Downloaded media source mismatch for $globalKey: have $downloadedSourceId, expected $expectedSourceId', - ); - return null; - } - if (!comparedBySourceId && mediaIndex != null && downloadedItem.mediaIndex != mediaIndex) { - appLogger.w( - 'Downloaded media index mismatch for $globalKey: have ${downloadedItem.mediaIndex}, expected $mediaIndex', + 'Downloaded version mismatch for $globalKey: have index ${downloadedItem.mediaIndex} ' + '(source ${downloadedItem.mediaSourceId}), expected index $mediaIndex ' + '(source ${mediaSourceId?.trim()})', ); return null; } diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 39494d3a..ddfd80f8 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -12,6 +12,7 @@ import '../models/download_models.dart'; import '../models/transcode_quality_preset.dart'; import '../mpv/models.dart'; import '../utils/app_logger.dart'; +import '../utils/downloaded_version_match.dart'; import '../utils/global_key_utils.dart'; import 'cached_playback_metadata_service.dart'; import 'download_storage_service.dart'; @@ -51,6 +52,29 @@ class PlaybackInitializationService { String ratingKey, { int mediaIndex = 0, String? selectedMediaSourceId, + }) async { + final source = await _resolveOfflineVideoSource( + serverId, + ratingKey, + mediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + ); + return source?.path; + } + + /// Resolve the downloaded copy of an item to its playable local path plus + /// the version that is actually on disk. + /// + /// Strict by default: a version mismatch returns null so online flows keep + /// streaming an explicitly requested non-downloaded version. With + /// [allowAnyDownloadedVersion] the single downloaded version is returned on + /// mismatch instead — for offline flows where the alternative is failing. + Future<({String path, int mediaIndex, String? mediaSourceId})?> _resolveOfflineVideoSource( + ServerId serverId, + String ratingKey, { + required int mediaIndex, + String? selectedMediaSourceId, + bool allowAnyDownloadedVersion = false, }) async { if (database == null) { return null; @@ -70,28 +94,25 @@ class PlaybackInitializationService { return null; } - final downloadedSourceId = downloadedItem.mediaSourceId; - final requestedSourceId = selectedMediaSourceId?.trim(); - final comparedBySourceId = - requestedSourceId != null && - requestedSourceId.isNotEmpty && - downloadedSourceId != null && - downloadedSourceId.isNotEmpty; - if (comparedBySourceId && downloadedSourceId != requestedSourceId) { + final matches = downloadedVersionMatches( + downloadedItem, + requestedMediaIndex: mediaIndex, + requestedMediaSourceId: selectedMediaSourceId, + ); + if (!matches) { + if (!allowAnyDownloadedVersion) { + appLogger.d( + '[VersionTrace] Offline video is version ${downloadedItem.mediaIndex} ' + '(source ${downloadedItem.mediaSourceId}), but requested version ' + '$mediaIndex (source ${selectedMediaSourceId?.trim()}) — skipping offline', + ); + return null; + } appLogger.d( - '[VersionTrace] Offline video source is $downloadedSourceId, ' - 'but requested source $requestedSourceId — skipping offline', + '[VersionTrace] Requested version $mediaIndex (source ${selectedMediaSourceId?.trim()}) ' + 'is not downloaded — falling back to downloaded version ' + '${downloadedItem.mediaIndex} (source ${downloadedItem.mediaSourceId})', ); - return null; - } - - // Fall back to index when either side lacks a stable source id. - if (!comparedBySourceId && downloadedItem.mediaIndex != mediaIndex) { - appLogger.d( - '[VersionTrace] Offline video is version ${downloadedItem.mediaIndex}, ' - 'but requested version $mediaIndex — skipping offline', - ); - return null; } // Return null if no video file path @@ -115,7 +136,11 @@ class PlaybackInitializationService { } appLogger.d('Found offline video: $readablePath'); - return readablePath; + return ( + path: readablePath, + mediaIndex: downloadedItem.mediaIndex, + mediaSourceId: downloadedItem.mediaSourceId, + ); } catch (e) { appLogger.w('Error checking offline video path', error: e); return null; @@ -140,24 +165,30 @@ class PlaybackInitializationService { }) async { final serverId = metadata.serverId ?? client?.serverId; - String? offlineVideoPath; + ({String path, int mediaIndex, String? mediaSourceId})? offlineSource; if (serverId != null && (preferOffline || client == null) && database != null) { - offlineVideoPath = await getOfflineVideoPath( + offlineSource = await _resolveOfflineVideoSource( ServerId(serverId), metadata.id, mediaIndex: selectedMediaIndex, selectedMediaSourceId: selectedMediaSourceId, + // With no client there is nothing to stream from, so any downloaded + // version beats failing. With a client the strict match must stand: + // an explicitly requested non-downloaded version streams from the + // server (issue #1440). + allowAnyDownloadedVersion: client == null, ); } // Downloaded playback must not wait on a live server. Cached media info // preserves track labels where available; the local file is enough to play. - if (offlineVideoPath != null) { + if (offlineSource != null) { appLogger.d('Using offline playback for ${metadata.id}'); return _buildOfflineResult( metadata: metadata, - offlineVideoPath: offlineVideoPath, - selectedMediaIndex: selectedMediaIndex, + offlineVideoPath: offlineSource.path, + selectedMediaIndex: offlineSource.mediaIndex, + selectedMediaSourceId: offlineSource.mediaSourceId, ); } @@ -189,6 +220,7 @@ class PlaybackInitializationService { required MediaItem metadata, required String offlineVideoPath, required int selectedMediaIndex, + String? selectedMediaSourceId, }) async { MediaSourceInfo? mediaInfo; try { @@ -219,6 +251,7 @@ class PlaybackInitializationService { isOffline: true, playMethod: 'DirectPlay', selectedMediaIndex: selectedMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, ); } diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index 43e53d08..840176f5 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -80,6 +80,13 @@ class PlaybackInitializationResult { /// Effective media version after backend clamping/fallback. final int selectedMediaIndex; + /// Stable source id of the effective media version, when known without a + /// version list. Set by the offline path (where [availableVersions] is + /// empty) so the session reflects the downloaded version actually played, + /// even when it differs from the requested one. Online backends leave this + /// null and the id is derived from [availableVersions] instead. + final String? selectedMediaSourceId; + /// 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; @@ -102,6 +109,7 @@ class PlaybackInitializationResult { this.playSessionId, this.playMethod, this.selectedMediaIndex = 0, + this.selectedMediaSourceId, }); } diff --git a/lib/services/playback_session.dart b/lib/services/playback_session.dart index 2e42a8d7..ad6a74a8 100644 --- a/lib/services/playback_session.dart +++ b/lib/services/playback_session.dart @@ -41,7 +41,9 @@ class PlaybackSession { context: context, qualityPreset: fellBackToOriginal ? TranscodeQualityPreset.original : requestedQualityPreset, mediaSourceId: - mediaSourceIdForIndex(result.availableVersions, result.selectedMediaIndex) ?? requestedMediaSourceId, + result.selectedMediaSourceId ?? + mediaSourceIdForIndex(result.availableVersions, result.selectedMediaIndex) ?? + requestedMediaSourceId, ); } diff --git a/lib/utils/downloaded_version_match.dart b/lib/utils/downloaded_version_match.dart new file mode 100644 index 00000000..5ce5613e --- /dev/null +++ b/lib/utils/downloaded_version_match.dart @@ -0,0 +1,30 @@ +import '../database/app_database.dart'; + +/// Single source of truth for "does this downloaded row satisfy a version +/// request". +/// +/// Source-id comparison wins when both sides have one — Jellyfin merged +/// versions can reorder between item fetches, so the stable id is the only +/// trustworthy discriminator there. Otherwise fall back to the media index. +/// A null [requestedMediaIndex] means "any version" (the caller has no +/// version opinion, e.g. external-player launches keyed by item only). +bool downloadedVersionMatches( + DownloadedMediaItem row, { + int? requestedMediaIndex, + String? requestedMediaSourceId, +}) { + final downloadedSourceId = row.mediaSourceId; + final requestedSourceId = requestedMediaSourceId?.trim(); + final comparedBySourceId = + requestedSourceId != null && + requestedSourceId.isNotEmpty && + downloadedSourceId != null && + downloadedSourceId.isNotEmpty; + if (comparedBySourceId) { + return downloadedSourceId == requestedSourceId; + } + if (requestedMediaIndex == null) { + return true; + } + return row.mediaIndex == requestedMediaIndex; +} diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 9e36efa0..5a779d4a 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -163,14 +163,30 @@ Future navigateToVideoPlayer( ? manager.getClient(serverId) : null; - final mediaIndex = selectedMediaIndex ?? await savedMediaVersionIndexFor(metadata) ?? 0; + // Plain Play on a downloaded item must target the version actually on + // disk. Only one version can be downloaded per item, and saved version + // preferences describe online intent — they may point at a version that + // was never downloaded (issue #1440). Explicit caller selections still win. + int? downloadedMediaIndex; + String? downloadedMediaSourceId; + if (isOffline && selectedMediaIndex == null && selectedMediaSourceId == null) { + final downloaded = await downloadProvider.getCompletedDownload(metadata.globalKey); + if (downloaded != null) { + downloadedMediaIndex = downloaded.mediaIndex; + downloadedMediaSourceId = downloaded.mediaSourceId; + } + } + + final mediaIndex = + selectedMediaIndex ?? downloadedMediaIndex ?? await savedMediaVersionIndexFor(metadata) ?? 0; + final mediaSourceId = selectedMediaSourceId ?? downloadedMediaSourceId; var markedInFlight = false; if (!usePushReplacement) { markedInFlight = _videoPlayerNavigationInFlightGuard.tryStart( metadata, mediaIndex: mediaIndex, - selectedMediaSourceId: selectedMediaSourceId, + selectedMediaSourceId: mediaSourceId, selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, ); @@ -195,7 +211,7 @@ Future navigateToVideoPlayer( final videoPath = await downloadProvider.getVideoFilePath( globalKey, mediaIndex: mediaIndex, - mediaSourceId: selectedMediaSourceId, + mediaSourceId: mediaSourceId, ); if (videoPath != null && context.mounted) { final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath'; @@ -206,7 +222,7 @@ Future navigateToVideoPlayer( client: mediaClient, offlineWatchService: offlineWatchService, mediaIndex: mediaIndex, - mediaSourceId: selectedMediaSourceId, + mediaSourceId: mediaSourceId, ); } } else if (context.mounted) { @@ -216,7 +232,7 @@ Future navigateToVideoPlayer( client: mediaClient, offlineWatchService: offlineWatchService, mediaIndex: mediaIndex, - mediaSourceId: selectedMediaSourceId, + mediaSourceId: mediaSourceId, ); } @@ -244,7 +260,7 @@ Future navigateToVideoPlayer( preferredSubtitleTrack: preferredSubtitleTrack, preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: mediaIndex, - selectedMediaSourceId: selectedMediaSourceId, + selectedMediaSourceId: mediaSourceId, selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, ), @@ -258,7 +274,7 @@ Future navigateToVideoPlayer( _videoPlayerNavigationInFlightGuard.finish( metadata, mediaIndex: mediaIndex, - selectedMediaSourceId: selectedMediaSourceId, + selectedMediaSourceId: mediaSourceId, selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, ); diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index f036c743..337e33f4 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -157,6 +157,59 @@ void main() { p.dispose(); }); + + test('getCompletedDownload exposes the downloaded version for owned completed rows', () async { + const globalKey = 'srv:movie-1'; + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'movie-1', + globalKey: globalKey, + type: 'movie', + status: DownloadStatus.completed.index, + mediaIndex: 1, + mediaSourceId: 'source-b', + ); + + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState(ownedDownloadKeys: {globalKey}); + + final row = await p.getCompletedDownload(globalKey); + expect(row, isNotNull); + expect(row!.mediaIndex, 1); + expect(row.mediaSourceId, 'source-b'); + + p.dispose(); + }); + + test('getCompletedDownload returns null for unowned or incomplete rows', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'unowned', + globalKey: 'srv:unowned', + type: 'movie', + status: DownloadStatus.completed.index, + ); + // Owned by another profile — otherwise legacy adoption claims fully + // ownerless rows for the active profile during initialization. + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:unowned'); + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'partial', + globalKey: 'srv:partial', + type: 'movie', + status: DownloadStatus.downloading.index, + ); + + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState(ownedDownloadKeys: {'srv:partial'}); + + expect(await p.getCompletedDownload('srv:unowned'), isNull); + expect(await p.getCompletedDownload('srv:partial'), isNull); + + p.dispose(); + }); }); group('DownloadProvider — sync rule CRUD', () { diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 6e8db88a..0e0b2cd1 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -160,6 +160,98 @@ void main() { expect(result.mediaInfo?.audioTracks.single.languageCode, 'fre'); }); + test('no-client playback falls back to the downloaded version on a default request', () async { + // Issue #1440: only the non-default version (index 1) is downloaded, but + // plain Play requests the default (index 0). With no client to stream + // from, the downloaded copy must play — with its own version metadata. + await _insertDownloaded( + db, + serverId: ServerId('srv-1'), + ratingKey: 'movie-1', + videoFilePath: 'content://offline/movie-1-v2', + mediaIndex: 1, + mediaSourceId: 'source-b', + ); + await PlexApiCache.instance.put( + ServerId('srv-1'), + '/library/metadata/movie-1', + _plexMetadataEnvelope(includeSecondVersion: true), + ); + + final result = await PlaybackInitializationService(database: db).getPlaybackData( + metadata: MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + ); + + expect(result.isOffline, isTrue); + expect(result.videoUrl, 'content://offline/movie-1-v2'); + expect(result.selectedMediaIndex, 1); + expect(result.selectedMediaSourceId, 'source-b'); + expect(result.mediaInfo?.audioTracks.single.languageCode, 'fre'); + }); + + test('no-client playback plays the local copy even on an explicit version mismatch', () async { + await _insertDownloaded( + db, + serverId: ServerId('srv-1'), + ratingKey: 'movie-1', + videoFilePath: 'content://offline/movie-1-v2', + mediaIndex: 1, + mediaSourceId: 'source-b', + ); + + final result = await PlaybackInitializationService(database: db).getPlaybackData( + metadata: MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'source-a', + ); + + expect(result.isOffline, isTrue); + expect(result.videoUrl, 'content://offline/movie-1-v2'); + expect(result.selectedMediaIndex, 1); + }); + + test('online explicit version mismatch keeps streaming from the server', () async { + // Online pinning guard: Play Version + Original on a NON-downloaded + // version runs the offline check first (preferOffline), but the strict + // mismatch must send it to the server, not the downloaded file. + await _insertDownloaded( + db, + serverId: ServerId('srv-1'), + ratingKey: 'movie-1', + videoFilePath: 'content://offline/movie-1-v2', + mediaIndex: 1, + mediaSourceId: 'source-b', + ); + final client = _StreamingPlaybackClient(serverId: ServerId('srv-1')); + + final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( + metadata: MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'source-a', + preferOffline: true, + ); + + expect(client.playbackInitializationCalls, 1); + expect(result.isOffline, isFalse); + expect(result.videoUrl, 'https://server/stream/0'); + }); + test('offline path falls back to media index when caller has no source id', () async { await _insertDownloaded( db, @@ -351,6 +443,28 @@ void main() { }); } +class _StreamingPlaybackClient implements MediaServerClient { + _StreamingPlaybackClient({required this.serverId}); + + @override + final ServerId serverId; + + int playbackInitializationCalls = 0; + + @override + Future getPlaybackInitialization(PlaybackInitializationOptions options) async { + playbackInitializationCalls++; + return PlaybackInitializationResult( + availableVersions: const [], + videoUrl: 'https://server/stream/${options.selectedMediaIndex}', + selectedMediaIndex: options.selectedMediaIndex, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + class _FailingPlaybackClient implements MediaServerClient { _FailingPlaybackClient({required this.serverId}); diff --git a/test/services/playback_session_test.dart b/test/services/playback_session_test.dart index 7a6624ec..d736e3de 100644 --- a/test/services/playback_session_test.dart +++ b/test/services/playback_session_test.dart @@ -75,6 +75,26 @@ void main() { ); expect(session.mediaSourceId, 'requested'); }); + + test('prefers the result source id over derived and requested ids', () { + // Offline fallback playback: the result names the downloaded version, + // which must win over the (stale) requested id even when a version + // list would derive something else. + final versions = [MediaVersion(id: 'v0'), MediaVersion(id: 'v1')]; + final session = PlaybackSession.fromContext( + _context( + PlaybackInitializationResult( + availableVersions: versions, + videoUrl: 'u', + selectedMediaIndex: 1, + selectedMediaSourceId: 'downloaded', + ), + ), + requestedQualityPreset: TranscodeQualityPreset.original, + requestedMediaSourceId: 'requested', + ); + expect(session.mediaSourceId, 'downloaded'); + }); }); test('forwarding getters mirror the resolver output', () { diff --git a/test/utils/downloaded_version_match_test.dart b/test/utils/downloaded_version_match_test.dart new file mode 100644 index 00000000..4b0b5f3d --- /dev/null +++ b/test/utils/downloaded_version_match_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/download_models.dart'; +import 'package:plezy/utils/downloaded_version_match.dart'; + +DownloadedMediaItem _row({int mediaIndex = 0, String? mediaSourceId}) { + return DownloadedMediaItem( + id: 1, + serverId: 'srv', + ratingKey: 'movie-1', + globalKey: 'srv:movie-1', + type: 'movie', + status: DownloadStatus.completed.index, + progress: 100, + downloadedBytes: 0, + retryCount: 0, + mediaIndex: mediaIndex, + mediaSourceId: mediaSourceId, + ); +} + +void main() { + group('downloadedVersionMatches', () { + test('source id wins when both sides have one', () { + // Equal ids match even when the index disagrees (Jellyfin merged + // versions reorder between fetches). + expect( + downloadedVersionMatches( + _row(mediaIndex: 1, mediaSourceId: 'src-a'), + requestedMediaIndex: 0, + requestedMediaSourceId: 'src-a', + ), + isTrue, + ); + // Different ids reject even when the index agrees. + expect( + downloadedVersionMatches( + _row(mediaIndex: 0, mediaSourceId: 'src-a'), + requestedMediaIndex: 0, + requestedMediaSourceId: 'src-b', + ), + isFalse, + ); + }); + + test('falls back to index when either side lacks a source id', () { + // Legacy pre-v15 row: NULL source id. + expect( + downloadedVersionMatches(_row(mediaIndex: 0), requestedMediaIndex: 0, requestedMediaSourceId: 'src-a'), + isTrue, + ); + expect( + downloadedVersionMatches(_row(mediaIndex: 1), requestedMediaIndex: 0, requestedMediaSourceId: 'src-a'), + isFalse, + ); + // Caller without a source id. + expect(downloadedVersionMatches(_row(mediaIndex: 1, mediaSourceId: 'src-a'), requestedMediaIndex: 1), isTrue); + expect(downloadedVersionMatches(_row(mediaIndex: 1, mediaSourceId: 'src-a'), requestedMediaIndex: 0), isFalse); + }); + + test('blank requested source id is treated as absent', () { + expect( + downloadedVersionMatches( + _row(mediaIndex: 0, mediaSourceId: 'src-a'), + requestedMediaIndex: 0, + requestedMediaSourceId: ' ', + ), + isTrue, + ); + }); + + test('null requested index means any version', () { + expect(downloadedVersionMatches(_row(mediaIndex: 1, mediaSourceId: 'src-a')), isTrue); + expect(downloadedVersionMatches(_row(mediaIndex: 1)), isTrue); + // But a source-id mismatch still rejects. + expect( + downloadedVersionMatches(_row(mediaIndex: 1, mediaSourceId: 'src-a'), requestedMediaSourceId: 'src-b'), + isFalse, + ); + }); + }); +}