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:
edde746
2026-07-27 17:44:52 +02:00
parent b202c62641
commit c45113fefe
5 changed files with 229 additions and 28 deletions
@@ -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);
+61 -1
View File
@@ -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_');