fix(downloads): report accurate status through repair and storage exhaustion
Supplementary repair runs over downloads whose video is already complete, so it no longer emits a downloading transition, and artwork updates carry the row's real status instead of asserting downloading. Previously a reconnect left completed downloads stuck at "downloading 0%" until the next DB read. Storage exhaustion fails every active row in one transaction, so failActiveDownloadsForStorageFull now returns the affected keys and each one gets a failed event; only the triggering key was announced before. The post-recovery database open also closes its handle before rethrowing. A failing storage-full write abandoned a drift background isolate and its SQLite handles on every retry.
This commit is contained in:
@@ -19,6 +19,7 @@ import 'package:plezy/media/download_resolution.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
import 'package:plezy/services/download_artwork_helpers.dart';
|
||||
@@ -1188,6 +1189,84 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('deferred supplementary repair', () {
|
||||
test('completed repair persists artwork without announcing downloading', () async {
|
||||
final fixture = await _createSupplementaryFixture(thumbPath: '/repair-thumb');
|
||||
final client = _SupplementaryClient(
|
||||
metadata: fixture.metadata,
|
||||
resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'),
|
||||
);
|
||||
await _seedCompletedPendingDownload(fixture, downloadSubtitles: false, downloadArtwork: true);
|
||||
final manager = DownloadManagerService(
|
||||
database: fixture.db,
|
||||
storageService: fixture.storage,
|
||||
clientResolver: (serverId, {clientScopeId}) => client,
|
||||
http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('image bytes'))),
|
||||
downloadsSupportedOverride: false,
|
||||
);
|
||||
final events = <DownloadProgress>[];
|
||||
final progressSubscription = manager.progressStream.listen(events.add);
|
||||
addTearDown(progressSubscription.cancel);
|
||||
addTearDown(manager.dispose);
|
||||
|
||||
await manager.repairPendingSupplementaryDownloads();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
events,
|
||||
isNot(contains(predicate<DownloadProgress>((event) => event.status == DownloadStatus.downloading))),
|
||||
);
|
||||
expect(
|
||||
events,
|
||||
contains(
|
||||
predicate<DownloadProgress>(
|
||||
(event) =>
|
||||
event.status == DownloadStatus.completed && event.thumbPath == artworkStorageKey('/repair-thumb'),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.thumbPath,
|
||||
artworkStorageKey('/repair-thumb'),
|
||||
);
|
||||
expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty);
|
||||
});
|
||||
|
||||
test('normal completion still announces artwork and subtitle steps', () async {
|
||||
final fixture = await _createSupplementaryFixture(thumbPath: '/normal-thumb');
|
||||
final client = _SupplementaryClient(
|
||||
metadata: fixture.metadata,
|
||||
resolution: () => const DownloadResolution(
|
||||
videoUrl: 'https://example.test/video',
|
||||
externalSubtitles: [DownloadSubtitleSpec(id: 1, url: 'https://example.test/subtitle/1', codec: 'srt')],
|
||||
),
|
||||
);
|
||||
await _seedCompletingDownload(fixture, downloadSubtitles: true, downloadArtwork: true);
|
||||
final manager = DownloadManagerService(
|
||||
database: fixture.db,
|
||||
storageService: fixture.storage,
|
||||
clientResolver: (serverId, {clientScopeId}) => client,
|
||||
http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('supplementary bytes'))),
|
||||
downloadsSupportedOverride: false,
|
||||
);
|
||||
final events = <DownloadProgress>[];
|
||||
final progressSubscription = manager.progressStream.listen(events.add);
|
||||
addTearDown(progressSubscription.cancel);
|
||||
addTearDown(manager.dispose);
|
||||
|
||||
await manager.debugHandleTaskStatus(
|
||||
TaskStatusUpdate(_downloadTask('current-task', fixture.metadata.globalKey), TaskStatus.complete),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final downloadingFiles = events
|
||||
.where((event) => event.status == DownloadStatus.downloading)
|
||||
.map((event) => event.currentFile);
|
||||
expect(downloadingFiles, containsAll(<String?>['artwork', 'subtitles']));
|
||||
expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('deletion cleanup', () {
|
||||
test('missing video still removes partial and subtitle sidecars', () async {
|
||||
resetSharedPreferencesForTest();
|
||||
@@ -1323,6 +1402,9 @@ void main() {
|
||||
downloadsSupportedOverride: false,
|
||||
);
|
||||
addTearDown(manager.dispose);
|
||||
final events = <DownloadProgress>[];
|
||||
final sub = manager.progressStream.listen(events.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await manager.debugHandleTaskStatus(
|
||||
TaskStatusUpdate(
|
||||
@@ -1331,6 +1413,14 @@ void main() {
|
||||
TaskFileSystemException('write failed: ENOSPC (No space left on device)'),
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(
|
||||
events
|
||||
.where((event) => event.status == DownloadStatus.failed && event.errorMessage == t.downloads.storageFull)
|
||||
.map((event) => event.globalKey)
|
||||
.toSet(),
|
||||
{currentKey, queuedKey},
|
||||
);
|
||||
|
||||
final current = await db.getDownloadedMedia(currentKey);
|
||||
final queued = await db.getDownloadedMedia(queuedKey);
|
||||
@@ -1847,7 +1937,7 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
Future<_SupplementaryFixture> _createSupplementaryFixture() async {
|
||||
Future<_SupplementaryFixture> _createSupplementaryFixture({String? thumbPath}) async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
@@ -1869,13 +1959,14 @@ Future<_SupplementaryFixture> _createSupplementaryFixture() async {
|
||||
kind: MediaKind.movie,
|
||||
serverId: ServerId('srv'),
|
||||
title: 'Movie',
|
||||
thumbPath: thumbPath,
|
||||
),
|
||||
);
|
||||
fixture.initializeCaches();
|
||||
await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/item-1', {
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{'ratingKey': 'item-1', 'type': 'movie', 'title': 'Movie'},
|
||||
{'ratingKey': 'item-1', 'type': 'movie', 'title': 'Movie', 'thumb': ?thumbPath},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1883,7 +1974,11 @@ Future<_SupplementaryFixture> _createSupplementaryFixture() async {
|
||||
return fixture;
|
||||
}
|
||||
|
||||
Future<void> _seedCompletingDownload(_SupplementaryFixture fixture, {required bool downloadSubtitles}) async {
|
||||
Future<void> _seedCompletingDownload(
|
||||
_SupplementaryFixture fixture, {
|
||||
required bool downloadSubtitles,
|
||||
bool downloadArtwork = false,
|
||||
}) async {
|
||||
await fixture.db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: fixture.metadata.id,
|
||||
@@ -1895,11 +1990,15 @@ Future<void> _seedCompletingDownload(_SupplementaryFixture fixture, {required bo
|
||||
await fixture.db.addToQueue(
|
||||
mediaGlobalKey: fixture.metadata.globalKey,
|
||||
downloadSubtitles: downloadSubtitles,
|
||||
downloadArtwork: false,
|
||||
downloadArtwork: downloadArtwork,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _seedCompletedPendingDownload(_SupplementaryFixture fixture, {required bool downloadSubtitles}) async {
|
||||
Future<void> _seedCompletedPendingDownload(
|
||||
_SupplementaryFixture fixture, {
|
||||
required bool downloadSubtitles,
|
||||
bool downloadArtwork = false,
|
||||
}) async {
|
||||
await fixture.db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: fixture.metadata.id,
|
||||
@@ -1911,7 +2010,7 @@ Future<void> _seedCompletedPendingDownload(_SupplementaryFixture fixture, {requi
|
||||
await fixture.db.addToQueue(
|
||||
mediaGlobalKey: fixture.metadata.globalKey,
|
||||
downloadSubtitles: downloadSubtitles,
|
||||
downloadArtwork: false,
|
||||
downloadArtwork: downloadArtwork,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1979,7 +2078,20 @@ class _SupplementaryClient implements MediaServerClient {
|
||||
}
|
||||
|
||||
@override
|
||||
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) => const [];
|
||||
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
|
||||
return buildArtworkSpecs(item, (path) => 'https://example.test$path');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PlaybackExtras> fetchPlaybackExtras(
|
||||
String itemId, {
|
||||
String? introPattern,
|
||||
String? creditsPattern,
|
||||
bool forceChapterFallback = false,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
return PlaybackExtras(chapters: const [], markers: const []);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
|
||||
@@ -17,9 +17,10 @@ import 'test_helpers/download_fixtures.dart';
|
||||
import 'test_helpers/prefs.dart';
|
||||
|
||||
final class _OpenTrackingInterceptor extends QueryInterceptor {
|
||||
_OpenTrackingInterceptor({this.failure});
|
||||
_OpenTrackingInterceptor({this.failure, this.updateFailure});
|
||||
|
||||
final Object? failure;
|
||||
final Object? updateFailure;
|
||||
var ensureOpenCalls = 0;
|
||||
var ensureOpenCompleted = false;
|
||||
var closed = false;
|
||||
@@ -35,6 +36,13 @@ final class _OpenTrackingInterceptor extends QueryInterceptor {
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> runUpdate(QueryExecutor executor, String statement, List<Object?> args) {
|
||||
final updateFailure = this.updateFailure;
|
||||
if (updateFailure != null) throw updateFailure;
|
||||
return executor.runUpdate(statement, args);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close(QueryExecutor inner) async {
|
||||
await inner.close();
|
||||
@@ -219,6 +227,58 @@ void main() {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('post-recovery download failure closes the reopened database before rethrowing', () async {
|
||||
resetSharedPreferencesForTest();
|
||||
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_recovery_update_failure_');
|
||||
final file = File('${tempDir.path}/plezy_downloads.db');
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
final failedOpen = _OpenTrackingInterceptor(
|
||||
failure: const FileSystemException('write failed: No space left on device'),
|
||||
);
|
||||
final updateError = StateError('injected post-recovery update failure');
|
||||
final reopened = _OpenTrackingInterceptor(updateFailure: updateError);
|
||||
AppDatabase? seeded;
|
||||
|
||||
try {
|
||||
seeded = AppDatabase.forTesting(NativeDatabase(file));
|
||||
await seeded.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: 'active',
|
||||
globalKey: 'srv:active',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.downloading.index,
|
||||
);
|
||||
await seeded.close();
|
||||
seeded = null;
|
||||
|
||||
var openAttempts = 0;
|
||||
final open = openAppDatabaseWithDownloadRecovery(
|
||||
openDatabase: () {
|
||||
return AppDatabase.open(
|
||||
isTvos: false,
|
||||
databaseFile: file,
|
||||
preferences: prefs,
|
||||
executorFactory: (databaseFile) {
|
||||
openAttempts++;
|
||||
final interceptor = openAttempts == 1 ? failedOpen : reopened;
|
||||
return NativeDatabase(databaseFile).interceptWith(interceptor);
|
||||
},
|
||||
);
|
||||
},
|
||||
recoverNativeDownloads: () async {},
|
||||
storageFullMessage: 'Storage full',
|
||||
);
|
||||
|
||||
await expectLater(open, throwsA(same(updateError)));
|
||||
expect(openAttempts, 2);
|
||||
expect(reopened.closed, isTrue);
|
||||
} finally {
|
||||
await seeded?.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-storage lazy database-open errors bypass download recovery', () async {
|
||||
resetSharedPreferencesForTest();
|
||||
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_open_error_');
|
||||
|
||||
Reference in New Issue
Block a user