Files
plezy/lib/services/downloaded_video_source.dart
edde746 371eab8637 fix(downloads): stream from the server when a downloaded SAF copy is unreachable
Playing a downloaded movie or episode failed outright once the storage
holding it was gone — an SD card ejected, a USB volume unplugged, the
files deleted from outside the app (issue #2101). The item still played
fine from the server, but Plezy never tried: it committed to the local
copy and surfaced mpv's open failure.

Resolution already refused a missing local copy and let the caller
stream instead, but only for filesystem paths. SAF `content://` URIs
skipped the check on the premise that they are always playable as
written. They are not: an unmounted volume answers
`FileNotFoundException: No root for <volume>`, and a revoked grant is
just as dead, while the row still reads `completed`.

Probe the stored document through SAF before preferring it, so an
unreachable copy resolves to nothing and every consumer degrades the
way a missing file already did — the player streams, and both
external-player entry points launch against the server instead of a
dead URI.

close #2101
2026-08-24 19:27:20 +02:00

86 lines
3.6 KiB
Dart

import 'dart:io';
import '../database/app_database.dart';
import '../models/download_models.dart';
import '../utils/app_logger.dart';
import '../utils/downloaded_version_match.dart';
import 'download_storage_service.dart';
import 'saf_storage_service.dart';
/// A downloaded copy resolved to a playable location, plus the version that is
/// actually on disk — which can differ from the requested one when
/// [resolveDownloadedVideoSource] was allowed to fall back.
typedef DownloadedVideoSource = ({String path, int mediaIndex, String? mediaSourceId});
/// Single source of truth for "where is the playable copy of this downloaded
/// row, and is it the version that was asked for".
///
/// Returns null when the row cannot back playback: the download is not
/// complete, it holds a different version than requested (unless
/// [allowAnyDownloadedVersion]), it has no stored video path, or the stored
/// file is no longer reachable.
///
/// Version matching is strict by default so online flows keep streaming an
/// explicitly requested non-downloaded version (issue #1440). With
/// [allowAnyDownloadedVersion] the downloaded version is returned on mismatch
/// instead — for offline flows where the alternative is failing outright.
///
/// Callers own their own preconditions (profile ownership, how the row was
/// looked up); this only judges the row itself.
Future<DownloadedVideoSource?> resolveDownloadedVideoSource(
DownloadedMediaItem row, {
int? requestedMediaIndex,
String? requestedMediaSourceId,
bool allowAnyDownloadedVersion = false,
}) async {
if (row.status != DownloadStatus.completed.index) {
appLogger.d('Download not complete for ${row.globalKey}. Status: ${row.status}');
return null;
}
if (!downloadedVersionMatches(
row,
requestedMediaIndex: requestedMediaIndex,
requestedMediaSourceId: requestedMediaSourceId,
)) {
if (!allowAnyDownloadedVersion) {
appLogger.d(
'[VersionTrace] Downloaded copy of ${row.globalKey} is version ${row.mediaIndex} '
'(source ${row.mediaSourceId}), but requested version $requestedMediaIndex '
'(source ${requestedMediaSourceId?.trim()}) — skipping offline',
);
return null;
}
appLogger.d(
'[VersionTrace] Requested version $requestedMediaIndex (source ${requestedMediaSourceId?.trim()}) '
'is not downloaded — falling back to downloaded version ${row.mediaIndex} '
'(source ${row.mediaSourceId})',
);
}
final storedPath = row.videoFilePath;
if (storedPath == null) {
appLogger.d('Video file path is null for ${row.globalKey}');
return null;
}
final storageService = DownloadStorageService.instance;
// Reachability, not just presence in the row. A removable SAF volume can be
// unmounted — or its grant revoked — while the row still reads `completed`,
// and a stale content:// URI is indistinguishable from a live one until the
// player fails to open it (issue #2101). File paths may be stored relative,
// so resolve them first; SAF URIs are already playable as written.
final readablePath = await storageService.getReadablePath(storedPath);
final isReachable = storageService.isSafUri(storedPath)
? await SafStorageService.ops.exists(storedPath, isDir: false)
: await File(readablePath).exists();
if (!isReachable) {
// Returning null is what lets the caller stream from the server instead.
appLogger.w('Offline video file not reachable: $readablePath (stored as: $storedPath)');
return null;
}
appLogger.d('Found offline video: $readablePath');
return (path: readablePath, mediaIndex: row.mediaIndex, mediaSourceId: row.mediaSourceId);
}