2509 lines
94 KiB
Dart
2509 lines
94 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/i18n/strings.g.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('storage exhaustion', () {
|
|
test('filesystem-full failure stops every active download and clears the queue', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
const currentKey = 'srv:item-1';
|
|
const queuedKey = 'srv:item-2';
|
|
|
|
await db.insertDownload(
|
|
serverId: ServerId('srv'),
|
|
ratingKey: 'item-1',
|
|
globalKey: currentKey,
|
|
type: 'movie',
|
|
status: DownloadStatus.downloading.index,
|
|
);
|
|
await db.updateBgTaskId(currentKey, 'current-task');
|
|
await db.addToQueue(mediaGlobalKey: currentKey);
|
|
await db.insertDownload(
|
|
serverId: ServerId('srv'),
|
|
ratingKey: 'item-2',
|
|
globalKey: queuedKey,
|
|
type: 'movie',
|
|
status: DownloadStatus.queued.index,
|
|
);
|
|
await db.updateBgTaskId(queuedKey, 'queued-task');
|
|
await db.addToQueue(mediaGlobalKey: queuedKey);
|
|
|
|
final manager = DownloadManagerService(
|
|
database: db,
|
|
storageService: DownloadStorageService.instance,
|
|
clientResolver: (serverId, {clientScopeId}) => null,
|
|
downloadsSupportedOverride: false,
|
|
);
|
|
addTearDown(manager.dispose);
|
|
|
|
await manager.debugHandleTaskStatus(
|
|
TaskStatusUpdate(
|
|
_downloadTask('current-task', currentKey),
|
|
TaskStatus.failed,
|
|
TaskFileSystemException('write failed: ENOSPC (No space left on device)'),
|
|
),
|
|
);
|
|
|
|
final current = await db.getDownloadedMedia(currentKey);
|
|
final queued = await db.getDownloadedMedia(queuedKey);
|
|
expect(current?.status, DownloadStatus.failed.index);
|
|
expect(queued?.status, DownloadStatus.failed.index);
|
|
expect(current?.bgTaskId, isNull);
|
|
expect(queued?.bgTaskId, isNull);
|
|
expect(current?.errorMessage, t.downloads.storageFull);
|
|
expect(queued?.errorMessage, t.downloads.storageFull);
|
|
expect(await db.select(db.downloadQueue).get(), isEmpty);
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|