fix(playback): play the downloaded version when offline close #1440
Plain Play requests the default media version (index 0 or the saved preference), but the offline resolvers rejected the single downloaded row when a non-default version was downloaded, then threw "No video URL available" with no client to fall back to. Three-part fix sharing one matcher (downloadedVersionMatches): - getPlaybackData falls back to the downloaded version when there is no client to stream from; the result now carries the effective mediaIndex/mediaSourceId so cached media info and the committed session describe the file actually played. - navigateToVideoPlayer seeds the selection from the download record for isOffline plays with no explicit version, covering the external player branch and offline-library plays with a reachable server. - PlaybackSession.fromContext prefers the result source id over the requested one, keeping in-player state in sync after a fallback. Online pinning is untouched: with a live client an explicitly requested non-downloaded version still streams from the server.
This commit is contained in:
@@ -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<DownloadedMediaItem?> 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<String?> 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -163,14 +163,30 @@ Future<bool?> 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<bool?> 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<bool?> navigateToVideoPlayer(
|
||||
client: mediaClient,
|
||||
offlineWatchService: offlineWatchService,
|
||||
mediaIndex: mediaIndex,
|
||||
mediaSourceId: selectedMediaSourceId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
}
|
||||
} else if (context.mounted) {
|
||||
@@ -216,7 +232,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
client: mediaClient,
|
||||
offlineWatchService: offlineWatchService,
|
||||
mediaIndex: mediaIndex,
|
||||
mediaSourceId: selectedMediaSourceId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,7 +260,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
selectedMediaIndex: mediaIndex,
|
||||
selectedMediaSourceId: selectedMediaSourceId,
|
||||
selectedMediaSourceId: mediaSourceId,
|
||||
selectedQualityPreset: selectedQualityPreset,
|
||||
isOffline: isOffline,
|
||||
),
|
||||
@@ -258,7 +274,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
_videoPlayerNavigationInFlightGuard.finish(
|
||||
metadata,
|
||||
mediaIndex: mediaIndex,
|
||||
selectedMediaSourceId: selectedMediaSourceId,
|
||||
selectedMediaSourceId: mediaSourceId,
|
||||
selectedQualityPreset: selectedQualityPreset,
|
||||
isOffline: isOffline,
|
||||
);
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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<PlaybackInitializationResult> 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});
|
||||
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user