Files
plezy/test/services/download_manager_service_test.dart
T

2454 lines
92 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:http/http.dart' as http;
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/exceptions/media_server_exceptions.dart';
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_server_client.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/download_artwork_helpers.dart';
import 'package:plezy/services/download_artwork_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/saf_storage_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/active_client_scope.dart';
import 'package:saf_util/saf_util_platform_interface.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() {
group('downloadExtensionFromUrl', () {
test('uses path extension when present', () {
expect(downloadExtensionFromUrl('https://example.com/movie.mkv?Container=mp4'), 'mkv');
});
test('uses Jellyfin Container query parameter when path has no extension', () {
expect(downloadExtensionFromUrl('https://example.com/Videos/item/stream?Static=true&Container=mkv'), 'mkv');
});
test('normalizes and sanitizes container extensions', () {
expect(downloadExtensionFromUrl('https://example.com/Videos/item/stream?Container=MKV,MP4'), 'mkv');
expect(downloadExtensionFromUrl('https://example.com/Videos/item/stream?Container=../bad'), isNull);
});
});
group('partitionNativeTasks', () {
test('separates the current task id from stale and duplicate tasks', () {
final tasks = [
_downloadTask('current', 'srv:item-1'),
_downloadTask('stale', 'srv:item-1'),
_downloadTask('current', 'srv:item-1'),
];
final partition = partitionNativeTasks(tasks, 'current');
expect(partition.current.map((task) => task.taskId), ['current', 'current']);
expect(partition.stale.map((task) => task.taskId), ['stale']);
});
test('treats every native task as stale when the row has no task id', () {
final tasks = [_downloadTask('first', 'srv:item-1'), _downloadTask('second', 'srv:item-1')];
final partition = partitionNativeTasks(tasks, null);
expect(partition.current, isEmpty);
expect(partition.stale, tasks);
});
});
group('artworkStorageKey', () {
test('removes Jellyfin api_key from persisted artwork keys', () {
final url = 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=secret-token';
expect(artworkStorageKey(url), 'https://jf.example/Items/item-1/Images/Primary?tag=abc');
expect(buildArtworkSpecs(_movie(thumbPath: url), (path) => path).single.localKey, isNot(contains('api_key')));
});
});
group('lookupMetadata', () {
test('active Jellyfin lookup does not fall back to the downloaded foreign user scope', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
await db
.into(db.connections)
.insert(
ConnectionsCompanion.insert(
id: 'jf-machine/user-a',
kind: 'jellyfin',
displayName: 'User A · Jellyfin',
configJson: jsonEncode({
'baseUrl': 'https://jf.example',
'serverName': 'Jellyfin',
'serverMachineId': 'jf-machine',
'userId': 'user-a',
'userName': 'User A',
'accessToken': 'token-a',
'deviceId': 'device-a',
}),
createdAt: DateTime.now().millisecondsSinceEpoch,
),
);
await db
.into(db.downloadedMedia)
.insert(
DownloadedMediaCompanion.insert(
serverId: ServerId('jf-machine'),
clientScopeId: const Value('jf-machine/user-a'),
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
type: 'movie',
status: DownloadStatus.completed.index,
),
);
await db
.into(db.apiCache)
.insert(
ApiCacheCompanion.insert(
cacheKey: 'jf-machine/user-a:/Users/user-a/Items/item-1',
data: jsonEncode({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Cached for User A'}),
pinned: const Value(true),
),
);
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) {
return _ScopedJellyfinClient(
serverId: ServerId(serverId),
scopedServerId: clientScopeId ?? 'jf-machine/user-b',
);
},
);
final item = await manager.lookupMetadata(ServerId('jf-machine'), 'item-1', preferActiveScope: true);
expect(item, isNull);
});
test('cold Jellyfin hydration resolves the active profile binding instead of the shared download scope', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
for (final userId in ['user-a', 'user-b']) {
final profileId = 'profile-${userId.substring(userId.length - 1)}';
await db
.into(db.connections)
.insert(
ConnectionsCompanion.insert(
id: 'jf-machine/$userId',
kind: 'jellyfin',
displayName: userId,
configJson: jsonEncode({
'baseUrl': 'https://jf.example',
'serverName': 'Jellyfin',
'serverMachineId': 'jf-machine',
'userId': userId,
'userName': userId,
'accessToken': 'token-$userId',
'deviceId': 'device',
}),
createdAt: 0,
),
);
await db
.into(db.profileConnections)
.insert(
ProfileConnectionsCompanion.insert(
profileId: profileId,
connectionId: 'jf-machine/$userId',
userIdentifier: userId,
),
);
await JellyfinApiCache.instance.put(ServerId('jf-machine/$userId'), '/Users/$userId/Items/item-1', {
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Cached for $userId',
});
await JellyfinApiCache.instance.pinForOffline(ServerId('jf-machine/$userId'), 'item-1');
}
await db.insertDownload(
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'jf-machine:item-1');
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:item-1');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
);
final all = await manager.getAllPinnedMetadata(preferActiveScope: true, activeProfileId: 'profile-b');
final item = await manager.lookupMetadata(
ServerId('jf-machine'),
'item-1',
preferActiveScope: true,
activeProfileId: 'profile-b',
);
expect(all.keys, ['jf-machine/user-b:item-1']);
expect(all.values.single.title, 'Cached for user-b');
expect(item?.title, 'Cached for user-b');
});
test('cold Plex hydration finds a server inside its persisted account configuration', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
final serverId = ServerId('plex-machine');
final scope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
await db
.into(db.connections)
.insert(
ConnectionsCompanion.insert(
id: 'plex-account-uuid',
kind: 'plex',
displayName: 'Plex account',
configJson: jsonEncode({
'servers': [
{'clientIdentifier': serverId},
],
}),
createdAt: 0,
),
);
await db.insertDownload(
serverId: serverId,
clientScopeId: serverId,
ratingKey: 'item-1',
globalKey: 'plex-machine:item-1',
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'plex-machine:item-1');
await PlexApiCache.instance.put(scope.cacheServerId, '/library/metadata/item-1', {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'item-1', 'type': 'movie', 'title': 'Offline Plex'},
],
},
});
await PlexApiCache.instance.pinForOffline(scope.cacheServerId, 'item-1');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
);
final all = await manager.getAllPinnedMetadata(preferActiveScope: true, activeProfileId: 'profile-b');
final item = await manager.lookupMetadata(
serverId,
'item-1',
preferActiveScope: true,
activeProfileId: 'profile-b',
);
expect(all['plex-machine:item-1']?.title, 'Offline Plex');
expect(item?.title, 'Offline Plex');
});
test('active Plex lookup selects the exact profile namespace without bare fallback', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
final serverId = ServerId('plex-machine');
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
Map<String, Object?> metadata(String title) => {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'item-1', 'type': 'movie', 'title': title},
],
},
};
await PlexApiCache.instance.put(scopeA.cacheServerId, '/library/metadata/item-1', metadata('Profile A'));
await PlexApiCache.instance.put(scopeB.cacheServerId, '/library/metadata/item-1', metadata('Profile B'));
await PlexApiCache.instance.put(serverId, '/library/metadata/item-1', metadata('Legacy bare'));
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (resolvedServerId, {clientScopeId}) =>
_ArtworkRepairClient(serverId: ServerId(resolvedServerId), items: const {}),
);
final item = await manager.lookupMetadata(
serverId,
'item-1',
preferActiveScope: true,
activeProfileId: 'profile-a',
);
final missing = await manager.lookupMetadata(
serverId,
'item-1',
preferActiveScope: true,
activeProfileId: 'profile-missing',
);
expect(item?.title, 'Profile A');
expect(missing, isNull);
});
test('SAF recovery resolves show year from cached show metadata', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/show-1', {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'show-1', 'type': 'show', 'title': 'The Show', 'year': 2008},
],
},
});
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
);
final year = await manager.debugResolveSafRecoveryShowYear(
testMediaItem(
id: 'ep-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
serverId: ServerId('srv-1'),
title: 'Episode from 2010',
year: 2010,
grandparentId: 'show-1',
grandparentTitle: 'The Show',
parentIndex: 1,
index: 1,
),
);
expect(year, 2008);
});
test('interrupted Plex logout transfer recovers from the physical row transfer scope', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
final serverId = ServerId('plex-machine');
final profileScope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final transferScope = buildPlexTransferScopeId(serverId);
await db.insertDownload(
serverId: serverId,
clientScopeId: profileScope,
ratingKey: 'item-1',
globalKey: 'plex-machine:item-1',
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(
profileId: 'profile-a',
globalKey: 'plex-machine:item-1',
backendId: MediaBackend.plex.id,
clientScopeId: profileScope,
);
await PlexApiCache.instance.put(profileScope.cacheServerId, '/library/metadata/item-1', {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'item-1', 'type': 'movie', 'title': 'Recovered transfer'},
],
},
});
await PlexApiCache.instance.pinForOffline(profileScope.cacheServerId, 'item-1');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
);
await manager.preparePlexMetadataForLogoutTransfer();
expect((await db.getDownloadedMedia('plex-machine:item-1'))?.clientScopeId, transferScope);
expect(
(await db.getDownloadOwner(profileId: 'profile-a', globalKey: 'plex-machine:item-1'))?.clientScopeId,
profileScope,
);
expect(
(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, 'item-1'))?.title,
'Recovered transfer',
);
await manager.adoptTransferredPlexMetadataForProfile('profile-a');
final recoveredOwner = await db.getDownloadOwner(profileId: 'profile-a', globalKey: 'plex-machine:item-1');
expect((await db.getDownloadedMedia('plex-machine:item-1'))?.clientScopeId, profileScope);
expect(recoveredOwner?.backend, MediaBackend.plex.id);
expect(recoveredOwner?.clientScopeId, profileScope);
expect(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, 'item-1'), isNull);
expect(
(await PlexApiCache.instance.getMetadata(profileScope.cacheServerId, 'item-1'))?.title,
'Recovered transfer',
);
});
test('missing Plex leaf survives full logout and rehydrates after replacement adoption', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
final serverId = ServerId('plex-machine');
const globalKey = 'plex-machine:item-1';
final originalScope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final transferScope = buildPlexTransferScopeId(serverId);
final destinationScope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
for (final profileId in const ['profile-a', 'profile-b']) {
await db
.into(db.profiles)
.insert(
ProfilesCompanion.insert(
id: profileId,
kind: 'local',
displayName: profileId,
configJson: '{}',
createdAt: 0,
),
);
}
await db.insertDownload(
serverId: serverId,
clientScopeId: originalScope,
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
mediaIndex: 2,
mediaSourceId: 'source-a',
);
await db.updateDownloadProgress(globalKey, 100, 900, 900);
await db.updateVideoFilePath(globalKey, 'downloads/plex-machine/item-1/video.mp4');
await db.addToQueue(mediaGlobalKey: globalKey, priority: 7, downloadSubtitles: false, downloadArtwork: true);
await db.addDownloadOwner(
profileId: 'profile-a',
globalKey: globalKey,
backendId: MediaBackend.plex.id,
clientScopeId: originalScope,
);
final before = (await db.getDownloadedMedia(globalKey))!;
final queueBefore = (await db.select(db.downloadQueue).get()).single;
expect(await PlexApiCache.instance.getMetadata(originalScope.cacheServerId, 'item-1'), isNull);
final transferManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
);
await transferManager.preparePlexMetadataForLogoutTransfer();
expect((await db.getDownloadedMedia(globalKey))?.clientScopeId, transferScope);
expect((await db.getDownloadOwner(profileId: 'profile-a', globalKey: globalKey))?.clientScopeId, originalScope);
expect(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, 'item-1'), isNull);
await db.clearAllDownloadOwners();
expect(await db.getDownloadOwnerCount(globalKey), 0);
await db.adoptLegacyDownloadsForProfile('profile-b');
await transferManager.adoptTransferredPlexMetadataForProfile('profile-b');
final adopted = (await db.getDownloadedMedia(globalKey))!;
final adoptedOwner = await db.getDownloadOwner(profileId: 'profile-b', globalKey: globalKey);
final queueAfter = (await db.select(db.downloadQueue).get()).single;
expect(adopted.clientScopeId, destinationScope);
expect(adoptedOwner?.backend, MediaBackend.plex.id);
expect(adoptedOwner?.clientScopeId, destinationScope);
expect(adopted.status, before.status);
expect(adopted.progress, before.progress);
expect(adopted.downloadedBytes, before.downloadedBytes);
expect(adopted.totalBytes, before.totalBytes);
expect(adopted.videoFilePath, before.videoFilePath);
expect(adopted.downloadedAt, before.downloadedAt);
expect(adopted.mediaIndex, before.mediaIndex);
expect(adopted.mediaSourceId, before.mediaSourceId);
expect(queueAfter.mediaGlobalKey, queueBefore.mediaGlobalKey);
expect(queueAfter.priority, queueBefore.priority);
expect(queueAfter.addedAt, queueBefore.addedAt);
expect(queueAfter.downloadSubtitles, queueBefore.downloadSubtitles);
expect(queueAfter.downloadArtwork, queueBefore.downloadArtwork);
expect(await PlexApiCache.instance.getMetadata(destinationScope.cacheServerId, 'item-1'), isNull);
final hydratedMetadata = testMediaItem(
id: 'item-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: serverId,
title: 'Rehydrated for profile B',
);
final client = _DirectCachePlexClient(
serverId: serverId,
scopedServerId: destinationScope,
metadata: hydratedMetadata,
);
final hydrationManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (resolvedServerId, {clientScopeId}) {
if (resolvedServerId != serverId.toString()) return null;
if (clientScopeId != null && clientScopeId != destinationScope) return null;
return client;
},
);
final hydrated = await hydrationManager.fetchAndPinMetadata(
serverId,
'item-1',
preferActiveScope: true,
activeProfileId: 'profile-b',
);
expect(hydrated?.title, 'Rehydrated for profile B');
expect(
(await PlexApiCache.instance.getMetadata(destinationScope.cacheServerId, 'item-1'))?.title,
'Rehydrated for profile B',
);
expect(
(await PlexApiCache.instance.getAllPinnedMetadata(cacheServerIds: {destinationScope.cacheServerId})).keys,
[globalKey],
);
expect(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, 'item-1'), isNull);
});
test('Jellyfin offline pinning keeps media segment cache rows with metadata', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
await JellyfinApiCache.instance.put(ServerId('jf-machine/user-a'), '/Users/user-a/Items/item-1', {
'Id': 'item-1',
'Type': 'Episode',
'Name': 'Episode',
});
await JellyfinApiCache.instance.put(ServerId('jf-machine/user-a'), '/MediaSegments/item-1', {
'Items': [
{'Type': 'Intro', 'StartTicks': 10000000, 'EndTicks': 20000000},
],
});
await JellyfinApiCache.instance.pinForOffline(ServerId('jf-machine/user-a'), 'item-1');
expect(await JellyfinApiCache.instance.isPinned(ServerId('jf-machine/user-a'), '/MediaSegments/item-1'), isTrue);
await JellyfinApiCache.instance.deleteForItem(ServerId('jf-machine/user-a'), 'item-1');
expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/Users/user-a/Items/item-1'), isNull);
expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/MediaSegments/item-1'), isNull);
});
test('adopted Jellyfin ownership cleans only the adopting user cache scope', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.profiles)
.insert(
ProfilesCompanion.insert(
id: 'profile-b',
kind: 'local',
displayName: 'Profile B',
configJson: '{}',
createdAt: now,
),
);
await db
.into(db.connections)
.insert(
ConnectionsCompanion.insert(
id: 'jf-machine/user-b',
kind: 'jellyfin',
displayName: 'User B',
configJson: jsonEncode({'serverMachineId': 'jf-machine', 'userId': 'user-b'}),
createdAt: now,
),
);
await db
.into(db.profileConnections)
.insert(
ProfileConnectionsCompanion.insert(
profileId: 'profile-b',
connectionId: 'jf-machine/user-b',
userIdentifier: 'user-b',
),
);
await db.insertDownload(
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
type: 'movie',
status: DownloadStatus.completed.index,
);
for (final userId in const ['user-a', 'user-b']) {
await JellyfinApiCache.instance.put(ServerId('jf-machine/$userId'), '/Users/$userId/Items/item-1', {
'Id': 'item-1',
'Type': 'Movie',
'Name': userId,
});
}
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
);
await db.adoptLegacyDownloadsForProfile('profile-b');
final owner = await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:item-1');
expect((await db.getDownloadedMedia('jf-machine:item-1'))?.clientScopeId, 'jf-machine/user-b');
expect(owner?.backend, MediaBackend.jellyfin.id);
expect(owner?.clientScopeId, 'jf-machine/user-b');
await db.removeDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:item-1');
await manager.deleteMetadataForOwner(
globalKey: 'jf-machine:item-1',
serverId: ServerId('jf-machine'),
itemId: 'item-1',
profileId: 'profile-b',
backendId: owner?.backend,
clientScopeId: owner?.clientScopeId,
);
expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-b'), '/Users/user-b/Items/item-1'), isNull);
expect(
await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/Users/user-a/Items/item-1'),
isNotNull,
);
});
test('artwork repair fetches full parent metadata and backfills thumb path', () async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_artwork_repair_test_');
final previousPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
addTearDown(() async {
DownloadStorageService.resetForTesting();
SettingsService.resetForTesting();
PathProviderPlatform.instance = previousPathProvider;
expect(PathProviderPlatform.instance, same(previousPathProvider));
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
});
final settings = await SettingsService.getInstance();
final storage = DownloadStorageService.instance;
await storage.initialize(settings);
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
await db
.into(db.downloadedMedia)
.insert(
DownloadedMediaCompanion.insert(
serverId: ServerId('srv'),
ratingKey: 'ep-1',
globalKey: 'srv:ep-1',
type: 'episode',
parentRatingKey: const Value('season-1'),
grandparentRatingKey: const Value('show-1'),
status: DownloadStatus.completed.index,
),
);
await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/ep-1', {
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'ep-1',
'type': 'episode',
'title': 'Episode',
'thumb': '/ep-thumb',
'parentRatingKey': 'season-1',
'parentTitle': 'Season 1',
'parentIndex': 1,
'grandparentRatingKey': 'show-1',
'grandparentTitle': 'Show',
},
],
},
});
await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/show-1', {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'show-1', 'type': 'show', 'title': 'Show', 'thumb': '/show-thumb'},
],
},
});
final client = _ArtworkRepairClient(
serverId: ServerId('srv'),
items: {
'show-1': testMediaItem(
id: 'show-1',
backend: MediaBackend.plex,
kind: MediaKind.show,
serverId: ServerId('srv'),
title: 'Show',
thumbPath: '/show-thumb',
clearLogoPath: '/show-logo',
artPath: '/show-art',
backgroundSquarePath: '/show-square',
),
},
);
final manager = DownloadManagerService(
database: db,
storageService: storage,
clientResolver: (serverId, {clientScopeId}) => client,
http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('image bytes'))),
);
await manager.repairMissingArtworkForDownloads();
expect(client.fetchCounts['show-1'], isNotNull);
expect(client.fetchCounts['show-1']!, greaterThan(0));
final logoPath = DownloadArtworkService.localPathSync(storage, ServerId('srv'), '/show-logo');
expect(logoPath, isNotNull);
expect(File(logoPath!).existsSync(), isTrue);
final row = await db.getDownloadedMedia('srv:ep-1');
expect(row?.thumbPath, artworkStorageKey('/ep-thumb'));
});
});
group('download durability', () {
test('pin failure occurs after durable queue creation and still starts processing', () async {
final fixture = await _createSupplementaryFixture();
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'),
);
await fixture.db.customStatement('''
CREATE TRIGGER reject_cache_pin
BEFORE UPDATE OF pinned ON api_cache
BEGIN
SELECT RAISE(ABORT, 'cache pin rejected');
END
''');
var processingAttempts = 0;
var observedDurablePair = false;
final processingStarted = Completer<void>();
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: true,
queueProcessorOverride: (_) async {
processingAttempts++;
final media = await fixture.db.getDownloadedMedia(fixture.metadata.globalKey);
final queue = await fixture.db.select(fixture.db.downloadQueue).get();
observedDurablePair = media?.status == DownloadStatus.queued.index && queue.length == 1;
if (!processingStarted.isCompleted) processingStarted.complete();
},
);
addTearDown(manager.dispose);
await manager.queueDownload(metadata: fixture.metadata, client: client);
await processingStarted.future;
expect(processingAttempts, 1);
expect(observedDurablePair, isTrue);
expect(await fixture.db.getDownloadedMedia(fixture.metadata.globalKey), isNotNull);
expect(await fixture.db.select(fixture.db.downloadQueue).get(), hasLength(1));
});
test('an already queued request refreshes policy and restarts processing', () async {
final fixture = await _createSupplementaryFixture();
await fixture.db.insertDownload(
serverId: ServerId('srv'),
ratingKey: fixture.metadata.id,
globalKey: fixture.metadata.globalKey,
type: 'movie',
status: DownloadStatus.queued.index,
);
await fixture.db.addToQueue(mediaGlobalKey: fixture.metadata.globalKey, priority: 1);
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'),
);
var processingAttempts = 0;
var progressEvents = 0;
final processingStarted = Completer<void>();
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: true,
queueProcessorOverride: (_) async {
processingAttempts++;
if (!processingStarted.isCompleted) processingStarted.complete();
},
);
final progressSubscription = manager.progressStream.listen((_) => progressEvents++);
addTearDown(progressSubscription.cancel);
addTearDown(manager.dispose);
await manager.queueDownload(
metadata: fixture.metadata,
client: client,
priority: 8,
downloadSubtitles: false,
downloadArtwork: false,
);
await processingStarted.future;
final queue = (await fixture.db.select(fixture.db.downloadQueue).get()).single;
expect(queue.priority, 8);
expect(queue.downloadSubtitles, isFalse);
expect(queue.downloadArtwork, isFalse);
expect(processingAttempts, 1);
expect(progressEvents, 1);
});
test('an active or completed request emits and starts no duplicate work', () async {
final fixture = await _createSupplementaryFixture();
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'),
);
for (final status in [DownloadStatus.downloading, DownloadStatus.paused, DownloadStatus.completed]) {
await fixture.db.insertDownload(
serverId: ServerId('srv'),
ratingKey: fixture.metadata.id,
globalKey: fixture.metadata.globalKey,
type: 'movie',
status: status.index,
);
var processingAttempts = 0;
var progressEvents = 0;
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: true,
queueProcessorOverride: (_) async {
processingAttempts++;
},
);
final progressSubscription = manager.progressStream.listen((_) => progressEvents++);
await manager.queueDownload(metadata: fixture.metadata, client: client);
await Future<void>.delayed(Duration.zero);
expect((await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.status, status.index);
expect(await fixture.db.select(fixture.db.downloadQueue).get(), isEmpty);
expect(processingAttempts, 0);
expect(progressEvents, 0);
await progressSubscription.cancel();
manager.dispose();
await fixture.db.deleteDownload(fixture.metadata.globalKey);
}
});
test('transient preparation failure remains queued and starts an automatic retry', () async {
final fixture = await _createSupplementaryFixture();
var attempts = 0;
final retryStarted = Completer<void>();
final retryGate = Completer<DownloadResolution>();
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () {
attempts++;
if (attempts == 1) {
throw MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: 500,
message: 'temporary PlaybackInfo failure',
);
}
if (!retryStarted.isCompleted) retryStarted.complete();
return retryGate.future;
},
);
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: true,
fileDownloaderInitializerOverride: () async {},
autoRetryDelay: Duration.zero,
);
addTearDown(manager.dispose);
await manager.queueDownload(metadata: fixture.metadata, client: client);
await retryStarted.future;
final stored = await fixture.db.getDownloadedMedia(fixture.metadata.globalKey);
expect(attempts, 2);
expect(stored?.retryCount, 1);
expect(await fixture.db.select(fixture.db.downloadQueue).get(), hasLength(1));
await fixture.db.updateDownloadStatus(fixture.metadata.globalKey, DownloadStatus.cancelled.index);
retryGate.complete(const DownloadResolution(videoUrl: 'https://example.test/video'));
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
});
test('preparation retries exhaust the configured app retry budget', () async {
final fixture = await _createSupplementaryFixture();
var attempts = 0;
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () {
attempts++;
throw MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: 500,
message: 'temporary PlaybackInfo failure',
);
},
);
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: true,
fileDownloaderInitializerOverride: () async {},
autoRetryDelay: Duration.zero,
);
addTearDown(manager.dispose);
final exhausted = manager.progressStream.firstWhere(
(event) => event.status == DownloadStatus.failed && attempts == 4,
);
await manager.queueDownload(metadata: fixture.metadata, client: client);
await exhausted;
final stored = await fixture.db.getDownloadedMedia(fixture.metadata.globalKey);
expect(attempts, 4);
expect(stored?.status, DownloadStatus.failed.index);
expect(stored?.retryCount, 4);
});
test('cold recovery repairs a legacy queue gap once before native recovery', () async {
final fixture = await _createSupplementaryFixture();
await fixture.db.insertDownload(
serverId: ServerId('srv'),
ratingKey: fixture.metadata.id,
globalKey: fixture.metadata.globalKey,
type: 'movie',
status: DownloadStatus.queued.index,
);
await fixture.reopenDatabase();
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'),
);
var nativeRecoveryCalls = 0;
var queueProcessingCalls = 0;
final processingStarted = Completer<void>();
final firstManager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: true,
nativeRecoveryOverride: () async {
nativeRecoveryCalls++;
},
queueProcessorOverride: (_) async {
queueProcessingCalls++;
await fixture.db.updateDownloadStatus(fixture.metadata.globalKey, DownloadStatus.downloading.index);
if (!processingStarted.isCompleted) processingStarted.complete();
},
);
await firstManager.recoverInterruptedDownloads();
expect(nativeRecoveryCalls, 1);
expect(await fixture.db.select(fixture.db.downloadQueue).get(), hasLength(1));
firstManager.resumeQueuedDownloads(client);
await processingStarted.future;
expect(queueProcessingCalls, 1);
firstManager.dispose();
final secondManager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: true,
nativeRecoveryOverride: () async {
nativeRecoveryCalls++;
},
queueProcessorOverride: (_) async {
queueProcessingCalls++;
},
);
addTearDown(secondManager.dispose);
await secondManager.recoverInterruptedDownloads();
secondManager.resumeQueuedDownloads(client);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(nativeRecoveryCalls, 2);
expect(queueProcessingCalls, 1);
expect(await fixture.db.select(fixture.db.downloadQueue).get(), hasLength(1));
expect(
(await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.status,
DownloadStatus.downloading.index,
);
});
test('isolates subtitle failures and repairs only missing tracks after restart', () async {
final fixture = await _createSupplementaryFixture();
final httpClient = _ScriptedSubtitleClient(failuresRemaining: {1: 1});
final subtitles = [
const DownloadSubtitleSpec(id: 1, url: 'https://example.test/subtitle/1', codec: 'srt'),
const DownloadSubtitleSpec(id: 2, url: 'https://example.test/subtitle/2', codec: 'srt'),
];
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () => DownloadResolution(videoUrl: 'https://example.test/video', externalSubtitles: subtitles),
);
await _seedCompletingDownload(fixture, downloadSubtitles: true);
final firstManager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
http: MediaServerHttpClient(client: httpClient),
downloadsSupportedOverride: false,
);
await firstManager.debugHandleTaskStatus(
TaskStatusUpdate(_downloadTask('current-task', fixture.metadata.globalKey), TaskStatus.complete),
);
final firstPath = await fixture.storage.getMovieSubtitlePath(fixture.metadata, 1, 'srt');
final secondPath = await fixture.storage.getMovieSubtitlePath(fixture.metadata, 2, 'srt');
expect(httpClient.trackRequests, [1, 2]);
expect(File(firstPath).existsSync(), isFalse);
expect(File(secondPath).existsSync(), isTrue);
expect((await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.status, DownloadStatus.completed.index);
expect(await fixture.db.getPendingSupplementaryQueueItems(), hasLength(1));
firstManager.dispose();
final storedVideoPath = (await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.videoFilePath;
final restartedManager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
http: MediaServerHttpClient(client: httpClient),
downloadsSupportedOverride: false,
);
addTearDown(restartedManager.dispose);
await restartedManager.repairPendingSupplementaryDownloads();
expect(httpClient.trackRequests, [1, 2, 1]);
expect(File(firstPath).existsSync(), isTrue);
expect(File(secondPath).existsSync(), isTrue);
expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty);
final completed = await fixture.db.getDownloadedMedia(fixture.metadata.globalKey);
expect(completed?.status, DownloadStatus.completed.index);
expect(completed?.videoFilePath, storedVideoPath);
expect(await fixture.db.getNextQueueItem(), isNull);
});
test('retains unresolved subtitle enrichment and settles a later authoritative empty list', () async {
final fixture = await _createSupplementaryFixture();
var enrichmentResolved = false;
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () =>
DownloadResolution(videoUrl: 'https://example.test/video', externalSubtitlesResolved: enrichmentResolved),
);
await _seedCompletedPendingDownload(fixture, downloadSubtitles: true);
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
await manager.repairPendingSupplementaryDownloads();
expect(await fixture.db.getPendingSupplementaryQueueItems(), hasLength(1));
enrichmentResolved = true;
await manager.repairPendingSupplementaryDownloads();
expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty);
});
test('supplementary repair reuses the persisted media source', () async {
final fixture = await _createSupplementaryFixture();
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () =>
const DownloadResolution(videoUrl: 'https://example.test/video', externalSubtitlesResolved: true),
);
await _seedCompletedPendingDownload(fixture, downloadSubtitles: true);
await fixture.db.updateDownloadMediaSource(fixture.metadata.globalKey, 'source-2');
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
await manager.repairPendingSupplementaryDownloads();
expect(client.lastMediaSourceId, 'source-2');
expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty);
});
test('subtitle-disabled completion issues no sidecar request or retry marker', () async {
final fixture = await _createSupplementaryFixture();
final httpClient = _ScriptedSubtitleClient();
final client = _SupplementaryClient(
metadata: fixture.metadata,
resolution: () => DownloadResolution(
videoUrl: 'https://example.test/video',
externalSubtitles: const [DownloadSubtitleSpec(id: 1, url: 'https://example.test/subtitle/1', codec: 'srt')],
),
);
await _seedCompletingDownload(fixture, downloadSubtitles: false);
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
http: MediaServerHttpClient(client: httpClient),
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
await manager.debugHandleTaskStatus(
TaskStatusUpdate(_downloadTask('current-task', fixture.metadata.globalKey), TaskStatus.complete),
);
expect(httpClient.trackRequests, isEmpty);
expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty);
expect(await fixture.db.select(fixture.db.downloadQueue).get(), isEmpty);
});
test('coalesces concurrent persisted supplementary repairs', () async {
final fixture = await _createSupplementaryFixture();
final gate = Completer<void>();
final requestStarted = Completer<void>();
final httpClient = _ScriptedSubtitleClient(gate: gate, requestStarted: requestStarted);
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 _seedCompletedPendingDownload(fixture, downloadSubtitles: true);
final manager = DownloadManagerService(
database: fixture.db,
storageService: fixture.storage,
clientResolver: (serverId, {clientScopeId}) => client,
http: MediaServerHttpClient(client: httpClient),
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
final firstRepair = manager.repairPendingSupplementaryDownloads();
await requestStarted.future;
final secondRepair = manager.repairPendingSupplementaryDownloads();
gate.complete();
await Future.wait([firstRepair, secondRepair]);
expect(httpClient.trackRequests, [1]);
expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty);
});
});
group('deletion cleanup', () {
test('missing video still removes partial and subtitle sidecars', () async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_delete_test_');
final previousPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
addTearDown(() async {
DownloadStorageService.resetForTesting();
SettingsService.resetForTesting();
PathProviderPlatform.instance = previousPathProvider;
expect(PathProviderPlatform.instance, same(previousPathProvider));
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
});
final settings = await SettingsService.getInstance();
final storage = DownloadStorageService.instance;
await storage.initialize(settings);
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
final videoPath = p.join(tmpRoot.path, 'support', 'downloads', 'srv', 'item-1', 'video.mkv');
final partial = File('$videoPath.part');
final subtitles = Directory(videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs'));
await partial.parent.create(recursive: true);
await partial.writeAsString('partial');
await subtitles.create(recursive: true);
await File(p.join(subtitles.path, '1.srt')).writeAsString('subtitle');
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: 'srv:item-1',
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.updateVideoFilePath('srv:item-1', videoPath);
final manager = DownloadManagerService(
database: db,
storageService: storage,
clientResolver: (serverId, {clientScopeId}) => null,
)..recoveryFuture = Future<void>.value();
addTearDown(manager.dispose);
await manager.deleteDownload('srv:item-1');
expect(await partial.exists(), isFalse);
expect(await subtitles.exists(), isFalse);
expect(await db.getDownloadedMedia('srv:item-1'), isNull);
});
test('filesystem and SAF episode deletion apply the same cleanup policy', () async {
final filesystem = await _runEpisodeDeletion(saf: false);
final saf = await _runEpisodeDeletion(saf: true);
expect(filesystem, saf);
expect(
filesystem,
const _DeletionResult(
rowDeleted: true,
cacheDeleted: true,
videoDeleted: true,
thumbnailDeleted: true,
subtitlesDeleted: true,
progressItems: [0, 1],
),
);
});
test('SAF deletion failure still cleans sidecars, cache, and database state', () async {
final result = await _runEpisodeDeletion(saf: true, failVideoDeletion: true);
expect(
result,
const _DeletionResult(
rowDeleted: true,
cacheDeleted: true,
videoDeleted: false,
thumbnailDeleted: true,
subtitlesDeleted: true,
progressItems: [0, 1],
),
);
});
test('movie, season, and show deletion agree across filesystem and SAF', () async {
for (final kind in [MediaKind.movie, MediaKind.season, MediaKind.show]) {
final filesystem = await _runContainerDeletion(kind: kind, saf: false);
final saf = await _runContainerDeletion(kind: kind, saf: true);
expect(filesystem, saf, reason: '${kind.id} deletion differs by storage backend');
expect(
filesystem,
const _ContainerDeletionResult(rowDeleted: true, cacheDeleted: true, directoryDeleted: true),
);
}
});
});
group('task session validation', () {
test('ignores progress from stale native task ids', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.downloading.index,
);
await db.updateBgTaskId(globalKey, 'current-task');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
final events = <DownloadProgress>[];
final sub = manager.progressStream.listen(events.add);
addTearDown(sub.cancel);
await manager.debugHandleTaskProgress(TaskProgressUpdate(_downloadTask('stale-task', globalKey), 0.5, 1000));
await Future<void>.delayed(Duration.zero);
expect(events, isEmpty);
await manager.debugHandleTaskProgress(TaskProgressUpdate(_downloadTask('current-task', globalKey), 0.5, 1000));
await Future<void>.delayed(Duration.zero);
expect(events, hasLength(1));
expect(events.single.globalKey, globalKey);
expect(events.single.progress, 50);
});
test('ignores terminal status from stale native task ids', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.downloading.index,
);
await db.updateBgTaskId(globalKey, 'current-task');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
await manager.debugHandleTaskStatus(TaskStatusUpdate(_downloadTask('stale-task', globalKey), TaskStatus.failed));
final row = await db.getDownloadedMedia(globalKey);
expect(row?.status, DownloadStatus.downloading.index);
expect(row?.errorMessage, isNull);
expect(row?.bgTaskId, 'current-task');
});
test('ignores terminal status when the current row is no longer downloading', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.updateBgTaskId(globalKey, 'current-task');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
await manager.debugHandleTaskStatus(
TaskStatusUpdate(_downloadTask('current-task', globalKey), TaskStatus.canceled),
);
final row = await db.getDownloadedMedia(globalKey);
expect(row?.status, DownloadStatus.completed.index);
expect(row?.bgTaskId, 'current-task');
expect(await db.getNextQueueItem(), isNull);
});
test('requeues current system cancel without in-memory context', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.downloading.index,
);
await db.updateBgTaskId(globalKey, 'current-task');
await db.updateDownloadSafRoot(globalKey, 'content://downloads');
await db.updateDownloadError(globalKey, 'transient failure');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
await manager.debugHandleTaskStatus(
TaskStatusUpdate(_downloadTask('current-task', globalKey), TaskStatus.canceled),
);
final row = await db.getDownloadedMedia(globalKey);
expect(row?.status, DownloadStatus.queued.index);
expect(row?.bgTaskId, isNull);
expect(row?.safRootUri, 'content://downloads');
expect(row?.errorMessage, 'transient failure');
expect(row?.retryCount, 1);
expect((await db.getNextQueueItem())?.mediaGlobalKey, globalKey);
});
});
group('resume handling', () {
test('failed native resume leaves paused row paused', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.paused.index,
);
await db.updateBgTaskId(globalKey, 'current-task');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
final resumed = await manager.debugTryResumeNativeTask(
globalKey,
'current-task',
taskForId: (_) async => _downloadTask('current-task', globalKey),
resumeTask: (_) async => false,
);
final row = await db.getDownloadedMedia(globalKey);
expect(resumed, isFalse);
expect(row?.status, DownloadStatus.paused.index);
expect(row?.bgTaskId, 'current-task');
});
test('successful native resume transitions to downloading', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.paused.index,
);
await db.updateBgTaskId(globalKey, 'current-task');
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
downloadsSupportedOverride: false,
);
addTearDown(manager.dispose);
final resumed = await manager.debugTryResumeNativeTask(
globalKey,
'current-task',
taskForId: (_) async => _downloadTask('current-task', globalKey),
resumeTask: (_) async => true,
);
final row = await db.getDownloadedMedia(globalKey);
expect(resumed, isTrue);
expect(row?.status, DownloadStatus.downloading.index);
expect(row?.bgTaskId, 'current-task');
});
});
group('SAF grant ownership', () {
Future<void> seedRow(
AppDatabase db,
String key, {
String? videoFilePath,
String? taskId,
DownloadStatus status = DownloadStatus.queued,
}) async {
final parts = key.split(':');
await db.insertDownload(
serverId: ServerId(parts.first),
ratingKey: parts.last,
globalKey: key,
type: 'movie',
status: status.index,
);
if (videoFilePath != null) {
await db.updateVideoFilePath(key, videoFilePath);
}
if (taskId != null) await db.updateBgTaskId(key, taskId);
}
test('root switches retain references and release only the final owner', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
await seedRow(db, 'srv:first');
await seedRow(db, 'srv:second');
DownloadLocationSnapshot location = (path: 'content://picked-a', type: 'saf');
final saf = _FakeSafStorage(
canonicalRoots: const {
'content://picked-a': 'content://root-a',
'content://picked-b': 'content://root-b',
'content://picked-b-alias': 'content://root-b',
'content://root-a': 'content://root-a',
'content://root-b': 'content://root-b',
},
persistedRoots: const {'content://root-a', 'content://root-b'},
);
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
safStorage: saf,
downloadsSupportedOverride: false,
downloadLocationReader: () => location,
downloadPathWriter: (value) async {
location = (path: value, type: location.type);
},
downloadPathTypeWriter: (value) async {
location = (path: location.path, type: value);
},
downloadStorageRefresher: () async {},
);
addTearDown(manager.dispose);
await manager.debugClaimDownloadSafRoot('srv:first', 'content://picked-a');
await manager.debugClaimDownloadSafRoot('srv:second', 'content://picked-a');
await manager.setDownloadLocation(path: 'content://picked-b', pathType: 'saf');
expect(saf.releaseCalls, isEmpty);
await manager.debugDeleteDownloadRowAndRelease('srv:first');
expect(saf.releaseCalls, isEmpty);
await manager.debugDeleteDownloadRowAndRelease('srv:second');
expect(saf.releaseCalls, ['content://root-a']);
await manager.setDownloadLocation(path: 'content://picked-b-alias', pathType: 'saf');
expect(saf.releaseCalls, ['content://root-a']);
await manager.resetDownloadLocation();
expect(saf.releaseCalls, ['content://root-a', 'content://root-b']);
});
test('a paused claim serializes ahead of a root switch and retry move', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
await seedRow(db, 'srv:item');
DownloadLocationSnapshot location = (path: 'content://picked-a', type: 'saf');
final claimEntered = Completer<void>();
final allowClaim = Completer<void>();
final saf = _FakeSafStorage(
persistedRoots: const {'content://root-a', 'content://root-b'},
resolveOverride: (uri) async {
if (uri == 'content://task-a') {
claimEntered.complete();
await allowClaim.future;
return 'content://root-a';
}
return switch (uri) {
'content://picked-a' || 'content://root-a' => 'content://root-a',
'content://picked-b' || 'content://root-b' => 'content://root-b',
_ => null,
};
},
);
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
safStorage: saf,
downloadsSupportedOverride: false,
downloadLocationReader: () => location,
downloadPathWriter: (value) async {
location = (path: value, type: location.type);
},
downloadPathTypeWriter: (value) async {
location = (path: location.path, type: value);
},
downloadStorageRefresher: () async {},
);
addTearDown(manager.dispose);
final claim = manager.debugClaimDownloadSafRoot('srv:item', 'content://task-a');
await claimEntered.future;
var switchCompleted = false;
final rootSwitch = manager
.setDownloadLocation(path: 'content://picked-b', pathType: 'saf')
.whenComplete(() => switchCompleted = true);
await Future<void>.delayed(Duration.zero);
expect(switchCompleted, isFalse);
expect(saf.releaseCalls, isEmpty);
allowClaim.complete();
await claim;
await rootSwitch;
expect((await db.getDownloadedMedia('srv:item'))?.safRootUri, 'content://root-a');
expect(saf.releaseCalls, isEmpty);
await manager.debugClaimDownloadSafRoot('srv:item', 'content://picked-b');
expect((await db.getDownloadedMedia('srv:item'))?.safRootUri, 'content://root-b');
expect(saf.releaseCalls, ['content://root-a']);
});
test('startup backfills legacy row and task roots then releases only orphans', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
await seedRow(db, 'srv:legacy', videoFilePath: 'content://child-a', status: DownloadStatus.completed);
await seedRow(db, 'srv:task', taskId: 'task-d', status: DownloadStatus.downloading);
final saf = _FakeSafStorage(
canonicalRoots: const {
'content://picked-b': 'content://root-b',
'content://child-a': 'content://root-a',
'content://dir-d': 'content://root-d',
'content://root-a': 'content://root-a',
'content://root-b': 'content://root-b',
'content://root-c': 'content://root-c',
'content://root-d': 'content://root-d',
},
persistedRoots: const {'content://root-a', 'content://root-b', 'content://root-c', 'content://root-d'},
);
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
safStorage: saf,
downloadsSupportedOverride: false,
downloadLocationReader: () => (path: 'content://picked-b', type: 'saf'),
);
addTearDown(manager.dispose);
final task = UriDownloadTask(
taskId: 'task-d',
url: 'https://example.test/video.mp4',
filename: 'video.mp4',
directoryUri: Uri.parse('content://dir-d'),
metaData: 'srv:task',
);
await manager.debugReconcileSafGrantOwnership(nativeTasks: [task]);
expect((await db.getDownloadedMedia('srv:legacy'))?.safRootUri, 'content://root-a');
expect((await db.getDownloadedMedia('srv:task'))?.safRootUri, 'content://root-d');
expect(saf.persistedRoots, {'content://root-a', 'content://root-b', 'content://root-d'});
expect(saf.releaseCalls, ['content://root-c']);
await manager.debugReconcileSafGrantOwnership(nativeTasks: [task]);
expect(saf.releaseCalls, ['content://root-c']);
});
test('a failed legacy root resolution defers every orphan release', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
await seedRow(db, 'srv:legacy', videoFilePath: 'content://unresolved-child', status: DownloadStatus.completed);
final saf = _FakeSafStorage(
canonicalRoots: const {
'content://picked-b': 'content://root-b',
'content://root-b': 'content://root-b',
'content://root-c': 'content://root-c',
},
persistedRoots: const {'content://root-b', 'content://root-c'},
);
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
safStorage: saf,
downloadsSupportedOverride: false,
downloadLocationReader: () => (path: 'content://picked-b', type: 'saf'),
);
addTearDown(manager.dispose);
await manager.debugReconcileSafGrantOwnership();
expect(saf.releaseCalls, isEmpty);
expect(saf.persistedRoots, {'content://root-b', 'content://root-c'});
});
for (final failurePoint in ['second preference write', 'storage refresh']) {
test('$failurePoint restores the old location without discarding a usable grant', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
DownloadLocationSnapshot location = (path: 'content://picked-a', type: 'saf');
var typeWrites = 0;
var refreshes = 0;
final saf = _FakeSafStorage(
canonicalRoots: const {
'content://picked-a': 'content://root-a',
'content://picked-b': 'content://root-b',
'content://root-a': 'content://root-a',
'content://root-b': 'content://root-b',
},
persistedRoots: const {'content://root-a', 'content://root-b'},
);
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
safStorage: saf,
downloadsSupportedOverride: false,
downloadLocationReader: () => location,
downloadPathWriter: (value) async {
location = (path: value, type: location.type);
},
downloadPathTypeWriter: (value) async {
typeWrites++;
if (failurePoint == 'second preference write' && typeWrites == 1) {
throw StateError('injected type write failure');
}
location = (path: location.path, type: value);
},
downloadStorageRefresher: () async {
refreshes++;
if (failurePoint == 'storage refresh' && refreshes == 1) {
throw StateError('injected refresh failure');
}
},
);
addTearDown(manager.dispose);
await expectLater(manager.setDownloadLocation(path: 'content://picked-b', pathType: 'saf'), throwsStateError);
expect(location, (path: 'content://picked-a', type: 'saf'));
if (failurePoint == 'second preference write') {
expect(saf.releaseCalls, ['content://root-b']);
} else {
expect(saf.releaseCalls, isEmpty);
expect(saf.persistedRoots, contains('content://root-b'));
}
});
}
test('rollback retains a newly selected root already claimed by a row', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
await seedRow(db, 'srv:item');
await db.updateDownloadSafRoot('srv:item', 'content://root-b');
DownloadLocationSnapshot location = (path: 'content://picked-a', type: 'saf');
final saf = _FakeSafStorage(
canonicalRoots: const {
'content://picked-a': 'content://root-a',
'content://picked-b': 'content://root-b',
'content://root-a': 'content://root-a',
'content://root-b': 'content://root-b',
},
persistedRoots: const {'content://root-a', 'content://root-b'},
);
var refreshes = 0;
final manager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
safStorage: saf,
downloadsSupportedOverride: false,
downloadLocationReader: () => location,
downloadPathWriter: (value) async {
location = (path: value, type: location.type);
},
downloadPathTypeWriter: (value) async {
location = (path: location.path, type: value);
},
downloadStorageRefresher: () async {
refreshes++;
if (refreshes == 1) throw StateError('injected refresh failure');
},
);
addTearDown(manager.dispose);
await expectLater(manager.setDownloadLocation(path: 'content://picked-b', pathType: 'saf'), throwsStateError);
expect(location, (path: 'content://picked-a', type: 'saf'));
expect(saf.releaseCalls, isEmpty);
expect(saf.persistedRoots, contains('content://root-b'));
});
});
}
Future<_SupplementaryFixture> _createSupplementaryFixture() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
final tempDir = await Directory.systemTemp.createTemp('download_manager_supplementary_test_');
final previousPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = FakePathProvider(tempDir);
final storage = DownloadStorageService.instance;
await storage.initialize(await SettingsService.getInstance());
final databaseFile = File(p.join(tempDir.path, 'downloads.sqlite'));
final fixture = _SupplementaryFixture(
tempDir: tempDir,
previousPathProvider: previousPathProvider,
databaseFile: databaseFile,
db: AppDatabase.forTesting(NativeDatabase(databaseFile)),
storage: storage,
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv'),
title: 'Movie',
),
);
fixture.initializeCaches();
await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/item-1', {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'item-1', 'type': 'movie', 'title': 'Movie'},
],
},
});
addTearDown(fixture.dispose);
return fixture;
}
Future<void> _seedCompletingDownload(_SupplementaryFixture fixture, {required bool downloadSubtitles}) async {
await fixture.db.insertDownload(
serverId: ServerId('srv'),
ratingKey: fixture.metadata.id,
globalKey: fixture.metadata.globalKey,
type: 'movie',
status: DownloadStatus.downloading.index,
);
await fixture.db.updateBgTaskId(fixture.metadata.globalKey, 'current-task');
await fixture.db.addToQueue(
mediaGlobalKey: fixture.metadata.globalKey,
downloadSubtitles: downloadSubtitles,
downloadArtwork: false,
);
}
Future<void> _seedCompletedPendingDownload(_SupplementaryFixture fixture, {required bool downloadSubtitles}) async {
await fixture.db.insertDownload(
serverId: ServerId('srv'),
ratingKey: fixture.metadata.id,
globalKey: fixture.metadata.globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
);
await fixture.db.updateVideoFilePath(fixture.metadata.globalKey, 'downloads/srv/item-1/video.mp4');
await fixture.db.addToQueue(
mediaGlobalKey: fixture.metadata.globalKey,
downloadSubtitles: downloadSubtitles,
downloadArtwork: false,
);
}
class _SupplementaryFixture {
_SupplementaryFixture({
required this.tempDir,
required this.previousPathProvider,
required this.databaseFile,
required this.db,
required this.storage,
required this.metadata,
});
final Directory tempDir;
final PathProviderPlatform previousPathProvider;
final File databaseFile;
AppDatabase db;
final DownloadStorageService storage;
final MediaItem metadata;
void initializeCaches() {
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
}
Future<void> reopenDatabase() async {
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
initializeCaches();
}
Future<void> dispose() async {
await db.close();
DownloadStorageService.resetForTesting();
SettingsService.resetForTesting();
PathProviderPlatform.instance = previousPathProvider;
expect(PathProviderPlatform.instance, same(previousPathProvider));
if (await tempDir.exists()) await tempDir.delete(recursive: true);
}
}
class _SupplementaryClient implements MediaServerClient {
_SupplementaryClient({required this.metadata, required this.resolution});
final MediaItem metadata;
final FutureOr<DownloadResolution> Function() resolution;
String? lastMediaSourceId;
@override
ServerId get serverId => ServerId('srv');
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
Future<MediaItem?> fetchItem(String id) async => id == metadata.id ? metadata : null;
@override
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
lastMediaSourceId = mediaSourceId;
return resolution();
}
@override
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) => const [];
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ScriptedSubtitleClient extends http.BaseClient {
_ScriptedSubtitleClient({Map<int, int>? failuresRemaining, this.gate, this.requestStarted})
: failuresRemaining = {...?failuresRemaining};
final Map<int, int> failuresRemaining;
final Completer<void>? gate;
final Completer<void>? requestStarted;
final List<int> trackRequests = [];
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final trackId = int.parse(request.url.pathSegments.last);
trackRequests.add(trackId);
if (requestStarted != null && !requestStarted!.isCompleted) requestStarted!.complete();
if (gate != null) await gate!.future;
final remainingFailures = failuresRemaining[trackId] ?? 0;
final statusCode = remainingFailures > 0 ? 500 : 200;
if (remainingFailures > 0) failuresRemaining[trackId] = remainingFailures - 1;
return http.StreamedResponse(
Stream<List<int>>.value(utf8.encode(statusCode == 200 ? 'subtitle' : 'failed')),
statusCode,
request: request,
);
}
}
Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDeletion = false}) async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_backend_delete_test_');
final previousPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
if (!saf) {
await storage.initialize(await SettingsService.getInstance());
}
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
final serverId = ServerId('srv');
const globalKey = 'srv:episode-1';
final episode = testMediaItem(
id: 'episode-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
serverId: serverId,
title: 'Pilot',
parentId: 'season-1',
parentIndex: 1,
grandparentId: 'show-1',
grandparentTitle: 'Show',
index: 1,
);
await PlexApiCache.instance.put(serverId, '/library/metadata/episode-1', {
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'episode-1',
'type': 'episode',
'title': 'Pilot',
'parentRatingKey': 'season-1',
'parentIndex': 1,
'grandparentRatingKey': 'show-1',
'grandparentTitle': 'Show',
'index': 1,
},
],
},
});
await PlexApiCache.instance.put(serverId, '/library/metadata/show-1', {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'show-1', 'type': 'show', 'title': 'Show', 'year': 2000},
],
},
});
await db.insertDownload(
serverId: serverId,
ratingKey: 'episode-1',
globalKey: globalKey,
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
status: DownloadStatus.completed.index,
);
const safVideoUri = 'content://episode-1.mkv';
final filesystemVideoPath = await storage.getEpisodeVideoPath(episode, 'mkv', showYear: 2000);
final storedVideoPath = saf ? safVideoUri : filesystemVideoPath;
if (!saf) {
await File(filesystemVideoPath).writeAsString('video');
}
await db.updateVideoFilePath(globalKey, storedVideoPath);
final thumbnail = File(await storage.getEpisodeThumbnailPath(episode, showYear: 2000));
await thumbnail.writeAsString('thumbnail');
final subtitles = await storage.getEpisodeSubtitlesDirectory(episode, showYear: 2000);
await File(p.join(subtitles.path, '1.srt')).writeAsString('subtitle');
final safStorage = _FakeSafStorage(failDeletes: failVideoDeletion ? {safVideoUri} : const {});
if (saf) {
safStorage.addEpisode(storage, episode, videoUri: safVideoUri, showYear: 2000);
}
final manager = DownloadManagerService(
database: db,
storageService: storage,
clientResolver: (serverId, {clientScopeId}) => null,
safStorage: safStorage,
downloadsSupportedOverride: false,
)..recoveryFuture = Future<void>.value();
final progress = <DeletionProgress>[];
final subscription = manager.deletionProgressStream.listen(progress.add);
try {
await manager.deleteDownload(globalKey);
await Future<void>.delayed(Duration.zero);
return _DeletionResult(
rowDeleted: await db.getDownloadedMedia(globalKey) == null,
cacheDeleted: await PlexApiCache.instance.getMetadata(serverId, 'episode-1') == null,
videoDeleted: saf ? !safStorage.existsSync(safVideoUri) : !await File(filesystemVideoPath).exists(),
thumbnailDeleted: !await thumbnail.exists(),
subtitlesDeleted: !await subtitles.exists(),
progressItems: progress.map((event) => event.currentItem).toList(),
);
} finally {
await subscription.cancel();
manager.dispose();
await db.close();
DownloadStorageService.resetForTesting();
SettingsService.resetForTesting();
PathProviderPlatform.instance = previousPathProvider;
expect(PathProviderPlatform.instance, same(previousPathProvider));
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
}
}
Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind, required bool saf}) async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_container_delete_test_');
final previousPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
if (!saf) await storage.initialize(await SettingsService.getInstance());
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
final serverId = ServerId('srv');
final id = '${kind.id}-1';
final globalKey = 'srv:$id';
final metadata = testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: kind,
serverId: serverId,
title: kind == MediaKind.movie ? 'Movie' : (kind == MediaKind.show ? 'Show' : 'Season 1'),
year: 2000,
parentId: kind == MediaKind.season ? 'show-1' : null,
grandparentTitle: kind == MediaKind.season ? 'Show' : null,
index: kind == MediaKind.season ? 1 : null,
);
await PlexApiCache.instance.put(serverId, '/library/metadata/$id', {
'MediaContainer': {
'Metadata': [
{
'ratingKey': id,
'type': kind.id,
'title': metadata.title,
'year': 2000,
if (kind == MediaKind.season) ...{'parentRatingKey': 'show-1', 'grandparentTitle': 'Show', 'index': 1},
},
],
},
});
await db.insertDownload(
serverId: serverId,
ratingKey: id,
globalKey: globalKey,
type: kind.id,
status: DownloadStatus.completed.index,
);
final safStorage = _FakeSafStorage();
Directory? filesystemDirectory;
if (saf) {
safStorage.addContainer(storage, metadata);
} else {
filesystemDirectory = switch (kind) {
MediaKind.movie => await storage.getMovieDirectory(metadata),
MediaKind.season => await storage.getSeasonDirectory(metadata),
MediaKind.show => await storage.getShowDirectory(metadata),
_ => throw StateError('Unsupported test kind: $kind'),
};
await File(p.join(filesystemDirectory.path, 'asset.bin')).writeAsString('asset');
}
final manager = DownloadManagerService(
database: db,
storageService: storage,
clientResolver: (serverId, {clientScopeId}) => null,
safStorage: safStorage,
downloadsSupportedOverride: false,
)..recoveryFuture = Future<void>.value();
try {
await manager.deleteDownload(globalKey);
return _ContainerDeletionResult(
rowDeleted: await db.getDownloadedMedia(globalKey) == null,
cacheDeleted: await PlexApiCache.instance.getMetadata(serverId, id) == null,
directoryDeleted: saf ? !safStorage.existsSync('content://target') : !await filesystemDirectory!.exists(),
);
} finally {
manager.dispose();
await db.close();
DownloadStorageService.resetForTesting();
SettingsService.resetForTesting();
PathProviderPlatform.instance = previousPathProvider;
expect(PathProviderPlatform.instance, same(previousPathProvider));
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
}
}
class _DeletionResult {
const _DeletionResult({
required this.rowDeleted,
required this.cacheDeleted,
required this.videoDeleted,
required this.thumbnailDeleted,
required this.subtitlesDeleted,
required this.progressItems,
});
final bool rowDeleted;
final bool cacheDeleted;
final bool videoDeleted;
final bool thumbnailDeleted;
final bool subtitlesDeleted;
final List<int> progressItems;
@override
bool operator ==(Object other) =>
other is _DeletionResult &&
rowDeleted == other.rowDeleted &&
cacheDeleted == other.cacheDeleted &&
videoDeleted == other.videoDeleted &&
thumbnailDeleted == other.thumbnailDeleted &&
subtitlesDeleted == other.subtitlesDeleted &&
_listEquals(progressItems, other.progressItems);
@override
int get hashCode => Object.hash(
rowDeleted,
cacheDeleted,
videoDeleted,
thumbnailDeleted,
subtitlesDeleted,
Object.hashAll(progressItems),
);
}
class _ContainerDeletionResult {
const _ContainerDeletionResult({
required this.rowDeleted,
required this.cacheDeleted,
required this.directoryDeleted,
});
final bool rowDeleted;
final bool cacheDeleted;
final bool directoryDeleted;
@override
bool operator ==(Object other) =>
other is _ContainerDeletionResult &&
rowDeleted == other.rowDeleted &&
cacheDeleted == other.cacheDeleted &&
directoryDeleted == other.directoryDeleted;
@override
int get hashCode => Object.hash(rowDeleted, cacheDeleted, directoryDeleted);
}
bool _listEquals(List<int> left, List<int> right) {
if (left.length != right.length) return false;
for (var i = 0; i < left.length; i++) {
if (left[i] != right[i]) return false;
}
return true;
}
class _FakeSafStorage implements SafStorageOperations {
_FakeSafStorage({
this.failDeletes = const {},
Map<String, String> canonicalRoots = const {},
Set<String> persistedRoots = const {},
this.resolveOverride,
}) : canonicalRoots = Map<String, String>.from(canonicalRoots),
persistedRoots = Set<String>.from(persistedRoots);
final Set<String> failDeletes;
final Map<String, String> canonicalRoots;
final Set<String> persistedRoots;
final Future<String?> Function(String uri)? resolveOverride;
final List<String> releaseCalls = [];
final Map<String, SafDocumentFile> _childrenByPath = {};
final Map<String, List<SafDocumentFile>> _childrenByUri = {};
final Set<String> _existing = {};
void addEpisode(
DownloadStorageService storage,
MediaItem episode, {
required String videoUri,
required int showYear,
}) {
const rootUri = 'content://downloads';
const showUri = 'content://show';
const seasonUri = 'content://season';
final show = _document(showUri, 'Show (2000)', isDir: true);
final season = _document(seasonUri, 'Season 01', isDir: true);
final video = _document(videoUri, storage.getEpisodeSafFileName(episode, 'mkv'), isDir: false);
_childrenByPath[_pathKey(rootUri, storage.getShowSafPathComponents(episode, showYear: showYear))] = show;
_childrenByPath[_pathKey(rootUri, storage.getEpisodeSafPathComponents(episode, showYear: showYear))] = season;
_childrenByUri[rootUri] = [show];
_childrenByUri[showUri] = [season];
_childrenByUri[seasonUri] = [video];
_childrenByUri[videoUri] = [];
_existing.addAll([rootUri, showUri, seasonUri, videoUri]);
}
void addContainer(DownloadStorageService storage, MediaItem metadata) {
const rootUri = 'content://downloads';
const targetUri = 'content://target';
const assetUri = 'content://asset';
final target = _document(targetUri, metadata.displayTitle, isDir: true);
final asset = _document(assetUri, 'asset.bin', isDir: false);
final components = switch (metadata.kind) {
MediaKind.movie => storage.getMovieSafPathComponents(metadata),
MediaKind.season => storage.getSeasonSafPathComponents(metadata),
MediaKind.show => storage.getShowSafPathComponents(metadata),
_ => throw StateError('Unsupported test kind: ${metadata.kind}'),
};
_childrenByPath[_pathKey(rootUri, components)] = target;
_childrenByUri[targetUri] = [asset];
_childrenByUri[assetUri] = [];
_existing.addAll([rootUri, targetUri, assetUri]);
if (metadata.kind == MediaKind.season) {
const showUri = 'content://show';
final show = _document(showUri, 'Show (2000)', isDir: true);
_childrenByPath[_pathKey(rootUri, storage.getShowSafPathComponents(metadata))] = show;
_childrenByUri[showUri] = [target];
_existing.add(showUri);
} else {
_childrenByUri[rootUri] = [target];
}
}
bool existsSync(String uri) => _existing.contains(uri);
@override
Future<String?> createNestedDirectories(String parentUri, List<String> pathComponents) async {
return _childrenByPath[_pathKey(parentUri, pathComponents)]?.uri;
}
@override
Future<SafDocumentFile?> getChild(String parentUri, List<String> names) async {
return _childrenByPath[_pathKey(parentUri, names)];
}
@override
Future<List<SafDocumentFile>?> list(String uri) async {
return List<SafDocumentFile>.from(_childrenByUri[uri] ?? const []);
}
@override
Future<String?> resolvePersistedPermissionUri(String uri) async {
final override = resolveOverride;
if (override != null) return override(uri);
return canonicalRoots[uri] ?? (_existing.contains(uri) ? uri : null);
}
@override
Future<List<String>?> getPersistedPermissionUris() async {
return persistedRoots.toList(growable: false);
}
@override
Future<bool> releasePersistedPermission(String uri) async {
releaseCalls.add(uri);
persistedRoots.remove(uri);
return true;
}
@override
Future<bool> exists(String uri, {required bool isDir}) async => _existing.contains(uri);
@override
Future<bool> delete(String uri, {required bool isDir}) async {
if (failDeletes.contains(uri)) return false;
_existing.remove(uri);
for (final children in _childrenByUri.values) {
children.removeWhere((child) => child.uri == uri);
}
return true;
}
String _pathKey(String parentUri, List<String> names) => '$parentUri/${names.join('/')}';
SafDocumentFile _document(String uri, String name, {required bool isDir}) {
return SafDocumentFile(uri: uri, name: name, isDir: isDir, length: 0, lastModified: 0);
}
}
DownloadTask _downloadTask(String taskId, String globalKey) {
return DownloadTask(
taskId: taskId,
url: 'https://example.test/video.mp4',
filename: 'video.mp4',
directory: 'downloads',
metaData: globalKey,
);
}
MediaItem _movie({String? thumbPath}) {
return testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: ServerId('jf-machine'),
thumbPath: thumbPath,
);
}
class _ScopedJellyfinClient implements MediaServerClient, ScopedMediaServerClient {
_ScopedJellyfinClient({required this.serverId, required this.scopedServerId});
@override
final ServerId serverId;
@override
final String scopedServerId;
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ArtworkRepairClient implements MediaServerClient {
_ArtworkRepairClient({required this.serverId, required this.items});
@override
final ServerId serverId;
final Map<String, MediaItem> items;
final fetchCounts = <String, int>{};
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
Future<MediaItem?> fetchItem(String id) async {
fetchCounts[id] = (fetchCounts[id] ?? 0) + 1;
return items[id];
}
@override
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
return buildArtworkSpecs(item, (path) => 'https://example.test$path');
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _DirectCachePlexClient implements MediaServerClient, ScopedMediaServerClient {
_DirectCachePlexClient({required this.serverId, required this.scopedServerId, required this.metadata});
@override
final ServerId serverId;
@override
final String scopedServerId;
final MediaItem metadata;
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
Future<MediaItem?> fetchItem(String id) async {
if (id != metadata.id) return null;
await PlexApiCache.instance.put(ServerId(scopedServerId), '/library/metadata/$id', {
'MediaContainer': {
'Metadata': [
{'ratingKey': metadata.id, 'type': metadata.kind.id, 'title': metadata.title},
],
},
});
return metadata;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}