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:
edde746
2026-07-03 10:19:55 +02:00
parent 8fa91fe444
commit fd4291aafb
10 changed files with 414 additions and 49 deletions
+22 -15
View File
@@ -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,
});
}
+3 -1
View File
@@ -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,
);
}
+30
View File
@@ -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;
}
+23 -7
View File
@@ -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});
+20
View File
@@ -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,
);
});
});
}