Files
plezy/test/services/downloaded_video_source_test.dart
T
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

119 lines
4.4 KiB
Dart

import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/downloaded_video_source.dart';
import 'package:plezy/services/saf_storage_service.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/download_fixtures.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/saf_fakes.dart';
/// A downloaded row only backs playback while its stored copy is actually
/// reachable. Returning null is what lets the caller stream from the server
/// instead of handing the player a dead path (issue #2101): a removable SAF
/// volume can be unmounted, and its documents disappear with it, while the row
/// still reads `completed`.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late Directory tmpRoot;
late PathProviderPlatform previousPathProvider;
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
tmpRoot = await Directory.systemTemp.createTemp('downloaded_video_source_test_');
previousPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
db = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() async {
await db.close();
DownloadStorageService.resetForTesting();
SafStorageService.setOpsForTesting(null);
SettingsService.resetForTesting();
PathProviderPlatform.instance = previousPathProvider;
if (await tmpRoot.exists()) {
await tmpRoot.delete(recursive: true);
}
});
Future<DownloadedMediaItem> completedRow(String storedPath) async {
const globalKey = 'srv-1:movie-1';
await db.insertDownload(
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
mediaIndex: 0,
mediaSourceId: 'source-a',
);
await db.updateVideoFilePath(globalKey, storedPath);
return (db.select(db.downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingle();
}
group('SAF copies', () {
const safUri = 'content://com.android.externalstorage.documents/tree/79F7-B648%3A/document/movie-1.mkv';
test('an unmounted volume yields no source so the caller can stream', () async {
final row = await completedRow(safUri);
final saf = FakeSafStorage(reachable: false);
SafStorageService.setOpsForTesting(saf);
expect(await resolveDownloadedVideoSource(row, requestedMediaIndex: 0), isNull);
expect(saf.existsCalls, [safUri], reason: 'the stored document URI is what must be probed');
});
test('a reachable document plays from the stored URI', () async {
final row = await completedRow(safUri);
SafStorageService.setOpsForTesting(FakeSafStorage(existing: {safUri}));
final source = await resolveDownloadedVideoSource(row, requestedMediaIndex: 0);
expect(source?.path, safUri);
expect(source?.mediaIndex, 0);
expect(source?.mediaSourceId, 'source-a');
});
test('reachability is not consulted for a version that would be skipped anyway', () async {
final row = await completedRow(safUri);
final saf = FakeSafStorage();
SafStorageService.setOpsForTesting(saf);
expect(await resolveDownloadedVideoSource(row, requestedMediaIndex: 1), isNull);
expect(saf.existsCalls, isEmpty);
});
});
group('file copies', () {
test('a deleted file yields no source', () async {
final row = await completedRow('${tmpRoot.path}/gone.mkv');
expect(await resolveDownloadedVideoSource(row, requestedMediaIndex: 0), isNull);
});
test('a present file resolves to its absolute path', () async {
final video = File('${tmpRoot.path}/movie-1.mkv');
await video.writeAsBytes(const <int>[0]);
final row = await completedRow(video.path);
final source = await resolveDownloadedVideoSource(row, requestedMediaIndex: 0);
expect(source?.path, video.path);
});
});
}