refactor(downloads): consolidate task and metadata flows

This commit is contained in:
edde746
2026-07-12 17:31:13 +02:00
parent 7ecadffdb1
commit 754c54761a
4 changed files with 220 additions and 223 deletions
+77 -134
View File
@@ -57,6 +57,8 @@ class _RelatedMetadataDownloadContext {
final ensuredArtworkKeys = <String>{}; final ensuredArtworkKeys = <String>{};
} }
typedef _MetadataHydrationResult = ({MediaItem? metadata, bool networkFilled, bool stale});
/// Provider for managing download state and operations. /// Provider for managing download state and operations.
class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin { class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
final DownloadManagerService _downloadManager; final DownloadManagerService _downloadManager;
@@ -332,26 +334,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath); _artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath);
// Look up metadata from the bulk-loaded map (O(1) instead of DB query per item) await _hydrateDownloadMetadata(item.globalKey, allMetadata, downloadRecord: item);
// Falls back to individual query for any unpinned entries (e.g., legacy data).
// The fallback dispatches by backend.
final cached =
allMetadata[item.globalKey] ??
await _downloadManager.lookupMetadata(ServerId(item.serverId), item.ratingKey, preferActiveScope: true);
if (cached != null) {
_metadata[item.globalKey] = cached;
// For episodes (show/season) and tracks (artist/album), also load
// parent metadata from the same map.
if (cached.isEpisode || cached.kind == MediaKind.track) {
_loadParentMetadataFromMap(
cached,
allMetadata,
clientScopeId:
_downloadManager.activeClientScopeIdForServer(ServerId(item.serverId)) ?? item.clientScopeId,
);
}
}
} }
// Load sync rules from database // Load sync rules from database
@@ -391,6 +374,47 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_metadataStore.loadParentMetadataFromMap(leaf, allMetadata, clientScopeId: clientScopeId); _metadataStore.loadParentMetadataFromMap(leaf, allMetadata, clientScopeId: clientScopeId);
} }
Future<_MetadataHydrationResult> _hydrateDownloadMetadata(
String globalKey,
Map<String, MediaItem> allMetadata, {
DownloadedMediaItem? downloadRecord,
bool fetchOnMiss = false,
bool Function()? isStale,
}) async {
final parsed = parseGlobalKey(globalKey);
if (parsed == null) return (metadata: null, networkFilled: false, stale: false);
var record = downloadRecord;
if (record == null) {
record = await _downloadManager.getDownloadedMedia(globalKey);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
}
var cached =
allMetadata[globalKey] ??
await _downloadManager.lookupMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
var networkFilled = false;
if (cached == null && fetchOnMiss && _downloads.containsKey(globalKey)) {
cached = await _downloadManager.fetchAndPinMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
networkFilled = cached != null;
}
if (cached != null) {
_metadata[globalKey] = cached;
if (cached.isEpisode || cached.kind == MediaKind.track) {
_loadParentMetadataFromMap(
cached,
allMetadata,
clientScopeId: _downloadManager.activeClientScopeIdForServer(parsed.serverId) ?? record?.clientScopeId,
);
}
}
return (metadata: cached, networkFilled: networkFilled, stale: false);
}
void _onProgressUpdate(DownloadProgress progress) { void _onProgressUpdate(DownloadProgress progress) {
appLogger.d('Progress update received: ${progress.globalKey} - ${progress.status} - ${progress.progress}%'); appLogger.d('Progress update received: ${progress.globalKey} - ${progress.status} - ${progress.progress}%');
@@ -896,48 +920,20 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex); final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex);
return queued ? 1 : 0; return queued ? 1 : 0;
} else if (metadata.kind == MediaKind.album || metadata.kind == MediaKind.artist) { } else if (metadata.kind == MediaKind.album || metadata.kind == MediaKind.artist) {
final hadMetadata = _metadata.containsKey(globalKey); return _withStashedMetadata(metadata, () => _queueMusicContainerDownload(metadata, client));
_metadata[globalKey] = metadata; } else if (metadata.isShow || metadata.isSeason) {
try { return _withStashedMetadata(
return await _queueMusicContainerDownload(metadata, client); metadata,
} catch (_) { () => _expandAndQueue(
if (!hadMetadata) _metadata.remove(globalKey); container: metadata,
rethrow; client: client,
}
} else if (metadata.isShow) {
// Stash metadata pre-queue so the UI can render the queueing state;
// roll back if expansion throws so the orphan doesn't linger.
final hadMetadata = _metadata.containsKey(globalKey);
_metadata[globalKey] = metadata;
try {
return await _queueShowDownload(
metadata,
client,
versionConfig: config, versionConfig: config,
filter: filter, filter: filter,
maxCount: maxCount, maxCount: maxCount,
skipExisting: false,
includeSpecials: includeSpecials, includeSpecials: includeSpecials,
); ),
} catch (_) { );
if (!hadMetadata) _metadata.remove(globalKey);
rethrow;
}
} else if (metadata.isSeason) {
final hadMetadata = _metadata.containsKey(globalKey);
_metadata[globalKey] = metadata;
try {
return await _queueSeasonDownload(
metadata,
client,
versionConfig: config,
filter: filter,
maxCount: maxCount,
includeSpecials: includeSpecials,
);
} catch (_) {
if (!hadMetadata) _metadata.remove(globalKey);
rethrow;
}
} else { } else {
throw Exception('Cannot download ${metadata.kind.id}'); throw Exception('Cannot download ${metadata.kind.id}');
} }
@@ -947,6 +943,22 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
} }
} }
Future<T> _withStashedMetadata<T>(MediaItem metadata, Future<T> Function() operation) async {
final globalKey = metadata.globalKey;
final previous = _metadata[globalKey];
_metadata[globalKey] = metadata;
try {
return await operation();
} catch (_) {
if (previous == null) {
_metadata.remove(globalKey);
} else {
_metadata[globalKey] = previous;
}
rethrow;
}
}
/// Queue every playable item from a collection/playlist for download. /// Queue every playable item from a collection/playlist for download.
/// ///
/// Movies, episodes, and tracks are queued directly. Shows and seasons are /// Movies, episodes, and tracks are queued directly. Shows and seasons are
@@ -1175,46 +1187,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return count; return count;
} }
/// Queue all episodes from a TV show for download
Future<int> _queueShowDownload(
MediaItem show,
MediaServerClient client, {
DownloadVersionConfig? versionConfig,
DownloadFilter filter = DownloadFilter.all,
int? maxCount,
bool includeSpecials = true,
}) async {
return _expandAndQueue(
container: show,
client: client,
versionConfig: versionConfig,
filter: filter,
maxCount: maxCount,
skipExisting: false,
includeSpecials: includeSpecials,
);
}
/// Queue all episodes from a season for download
Future<int> _queueSeasonDownload(
MediaItem season,
MediaServerClient client, {
DownloadVersionConfig? versionConfig,
DownloadFilter filter = DownloadFilter.all,
int? maxCount,
bool includeSpecials = true,
}) async {
return _expandAndQueue(
container: season,
client: client,
versionConfig: versionConfig,
filter: filter,
maxCount: maxCount,
skipExisting: false,
includeSpecials: includeSpecials,
);
}
/// Queue only the missing (not downloaded) episodes for a show/season. /// Queue only the missing (not downloaded) episodes for a show/season.
/// Used for resuming partial downloads. Returns the number of episodes queued. /// Used for resuming partial downloads. Returns the number of episodes queued.
Future<int> queueMissingEpisodes( Future<int> queueMissingEpisodes(
@@ -1476,44 +1448,15 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
int misses = 0; int misses = 0;
for (final globalKey in keys) { for (final globalKey in keys) {
final parsed = parseGlobalKey(globalKey);
if (parsed == null) continue;
try { try {
final downloadRecord = await _downloadManager.getDownloadedMedia(globalKey); final result = await _hydrateDownloadMetadata(globalKey, allMetadata, fetchOnMiss: true, isStale: isStale);
if (isStale()) return; if (result.stale) return;
var cached = if (result.metadata == null) {
allMetadata[globalKey] ??
await _downloadManager.lookupMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true);
if (isStale()) return;
if (cached != null) {
cacheHits++;
} else if (_downloads.containsKey(globalKey)) {
// Cache miss for an item we know is downloaded — pull from the
// live server. Repairs profiles where the per-backend cache row
// was never written or got cleared, the case that produces
// empty-title sync rules and a missing-downloads list.
cached = await _downloadManager.fetchAndPinMetadata(
parsed.serverId,
parsed.ratingKey,
preferActiveScope: true,
);
if (isStale()) return;
if (cached != null) networkFills++;
}
if (cached != null) {
_metadata[globalKey] = cached;
if (cached.isEpisode || cached.kind == MediaKind.track) {
_loadParentMetadataFromMap(
cached,
allMetadata,
clientScopeId:
_downloadManager.activeClientScopeIdForServer(parsed.serverId) ?? downloadRecord?.clientScopeId,
);
}
} else {
misses++; misses++;
} else if (result.networkFilled) {
networkFills++;
} else {
cacheHits++;
} }
} catch (e) { } catch (e) {
appLogger.d('Failed to refresh metadata for $globalKey: $e'); appLogger.d('Failed to refresh metadata for $globalKey: $e');
+83 -88
View File
@@ -39,6 +39,22 @@ typedef _NativeTaskForId = Future<Task?> Function(String taskId);
typedef _NativeResumeTask = Future<bool> Function(DownloadTask task); typedef _NativeResumeTask = Future<bool> Function(DownloadTask task);
typedef _EpisodeStorageDeletion = ({String? seasonDirUri, String? showDirUri}); typedef _EpisodeStorageDeletion = ({String? seasonDirUri, String? showDirUri});
typedef NativeTaskPartition = ({List<Task> current, List<Task> stale});
@visibleForTesting
NativeTaskPartition partitionNativeTasks(Iterable<Task> tasks, String? currentTaskId) {
final current = <Task>[];
final stale = <Task>[];
for (final task in tasks) {
if (currentTaskId != null && task.taskId == currentTaskId) {
current.add(task);
} else {
stale.add(task);
}
}
return (current: current, stale: stale);
}
const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
class _DownloadContext { class _DownloadContext {
@@ -593,22 +609,27 @@ class DownloadManagerService {
} }
} }
Future<void> _reconcileDownloadingNativeTasks(DownloadedMediaItem row, List<Task> tasks) async { Future<int> _retainUniqueCurrentNativeTask(
final currentTaskId = row.bgTaskId; DownloadedMediaItem row,
final matchingCurrentTasks = currentTaskId == null List<Task> tasks, {
? const <Task>[] required String statusLabel,
: tasks.where((task) => task.taskId == currentTaskId).toList(growable: false); }) async {
if (matchingCurrentTasks.length == 1) { final partition = partitionNativeTasks(tasks, row.bgTaskId);
await _cancelNativeTaskIds( if (partition.current.length != 1) return partition.current.length;
row.globalKey, await _cancelNativeTaskIds(
tasks.where((task) => task.taskId != currentTaskId).map((task) => task.taskId), row.globalKey,
reason: 'duplicate downloading task during recovery', partition.stale.map((task) => task.taskId),
); reason: 'duplicate $statusLabel task during recovery',
return; );
} return 1;
}
if (matchingCurrentTasks.length > 1) { Future<void> _reconcileDownloadingNativeTasks(DownloadedMediaItem row, List<Task> tasks) async {
appLogger.w('Multiple native tasks share current task id $currentTaskId for ${row.globalKey}; re-queueing'); final currentMatchCount = await _retainUniqueCurrentNativeTask(row, tasks, statusLabel: 'downloading');
if (currentMatchCount == 1) return;
if (currentMatchCount > 1) {
appLogger.w('Multiple native tasks share current task id ${row.bgTaskId} for ${row.globalKey}; re-queueing');
await _cancelNativeTaskIds( await _cancelNativeTaskIds(
row.globalKey, row.globalKey,
tasks.map((task) => task.taskId), tasks.map((task) => task.taskId),
@@ -640,22 +661,12 @@ class DownloadManagerService {
} }
Future<void> _reconcilePausedNativeTasks(DownloadedMediaItem row, List<Task> tasks) async { Future<void> _reconcilePausedNativeTasks(DownloadedMediaItem row, List<Task> tasks) async {
final currentTaskId = row.bgTaskId; final currentMatchCount = await _retainUniqueCurrentNativeTask(row, tasks, statusLabel: 'paused');
final matchingCurrentTasks = currentTaskId == null if (currentMatchCount == 1) return;
? const <Task>[]
: tasks.where((task) => task.taskId == currentTaskId).toList(growable: false);
if (matchingCurrentTasks.length == 1) {
await _cancelNativeTaskIds(
row.globalKey,
tasks.where((task) => task.taskId != currentTaskId).map((task) => task.taskId),
reason: 'duplicate paused task during recovery',
);
return;
}
if (matchingCurrentTasks.length > 1) { if (currentMatchCount > 1) {
appLogger.w( appLogger.w(
'Multiple paused native tasks share current task id $currentTaskId for ${row.globalKey}; clearing task', 'Multiple paused native tasks share current task id ${row.bgTaskId} for ${row.globalKey}; clearing task',
); );
} }
@@ -1090,6 +1101,13 @@ class DownloadManagerService {
await _database.updateDownloadProgress(globalKey, 0, 0, 0); await _database.updateDownloadProgress(globalKey, 0, 0, 0);
} }
Future<void> _requeueDownload(String globalKey, {MediaServerClient? fallbackClient}) async {
await _transitionStatus(globalKey, DownloadStatus.queued);
await _database.addToQueue(mediaGlobalKey: globalKey);
final client = await _getClientForDownloadKey(globalKey) ?? fallbackClient;
if (client != null) unawaited(_processQueue(client));
}
Future<void> _cancelNativeTask(String globalKey, String taskId, {required String reason}) async { Future<void> _cancelNativeTask(String globalKey, String taskId, {required String reason}) async {
if (!downloadsSupported || taskId.isEmpty) return; if (!downloadsSupported || taskId.isEmpty) return;
try { try {
@@ -1140,14 +1158,16 @@ class DownloadManagerService {
String taskId, { String taskId, {
required String event, required String event,
bool cancelStale = false, bool cancelStale = false,
DownloadStatus? requiredStatus,
}) async { }) async {
final existing = await _database.getDownloadedMedia(globalKey); final existing = await _database.getDownloadedMedia(globalKey);
final currentTaskId = existing?.bgTaskId; final currentTaskId = existing?.bgTaskId;
if (existing != null && currentTaskId == taskId) return existing; final statusMatches = requiredStatus == null || existing?.status == requiredStatus.index;
if (existing != null && currentTaskId == taskId && statusMatches) return existing;
appLogger.d( appLogger.d(
'Ignoring stale download $event for $globalKey from task $taskId ' 'Ignoring stale download $event for $globalKey from task $taskId '
'(current task: ${currentTaskId ?? 'none'})', '(current task: ${currentTaskId ?? 'none'}, status: ${existing?.status ?? 'none'})',
); );
if (cancelStale) { if (cancelStale) {
await _cancelNativeTask(globalKey, taskId, reason: 'stale $event'); await _cancelNativeTask(globalKey, taskId, reason: 'stale $event');
@@ -1489,17 +1509,10 @@ class DownloadManagerService {
update.task.taskId, update.task.taskId,
event: 'status ${update.status}', event: 'status ${update.status}',
cancelStale: _isNativeTaskActiveStatus(update.status), cancelStale: _isNativeTaskActiveStatus(update.status),
requiredStatus: DownloadStatus.downloading,
); );
if (existing == null) return; if (existing == null) return;
if (existing.status != DownloadStatus.downloading.index) {
appLogger.d('Ignoring ${update.status} for inactive download $globalKey from task ${update.task.taskId}');
if (_isNativeTaskActiveStatus(update.status)) {
await _cancelNativeTask(globalKey, update.task.taskId, reason: 'status for inactive download');
}
return;
}
try { try {
switch (update.status) { switch (update.status) {
case TaskStatus.complete: case TaskStatus.complete:
@@ -1535,24 +1548,20 @@ class DownloadManagerService {
Future<void> _onDownloadCanceled(String globalKey, String taskId) async { Future<void> _onDownloadCanceled(String globalKey, String taskId) async {
if (_completingKeys.contains(globalKey)) return; if (_completingKeys.contains(globalKey)) return;
final existing = await _database.getDownloadedMedia(globalKey); final existing = await _downloadForCurrentTaskSession(
if (existing == null || globalKey,
existing.bgTaskId != taskId || taskId,
existing.status != DownloadStatus.downloading.index || event: 'system cancellation',
existing.status == DownloadStatus.completed.index || requiredStatus: DownloadStatus.downloading,
existing.status == DownloadStatus.cancelled.index) { );
return; if (existing == null) return;
}
_cancelDownloadTimers(globalKey); _cancelDownloadTimers(globalKey);
_pendingDownloadContext.remove(globalKey); _pendingDownloadContext.remove(globalKey);
appLogger.w('Download cancelled by system for $globalKey, re-queuing'); appLogger.w('Download cancelled by system for $globalKey, re-queuing');
await _database.updateBgTaskId(globalKey, null); await _database.updateBgTaskId(globalKey, null);
await _transitionStatus(globalKey, DownloadStatus.queued); await _requeueDownload(globalKey);
await _database.addToQueue(mediaGlobalKey: globalKey);
final client = await _getClientForDownloadKey(globalKey);
if (client != null) unawaited(_processQueue(client));
} }
/// Handle a failed download — auto-retry if retries remain, otherwise permanently fail. /// Handle a failed download — auto-retry if retries remain, otherwise permanently fail.
@@ -1567,15 +1576,13 @@ class DownloadManagerService {
return; return;
} }
final existing = await _database.getDownloadedMedia(globalKey); final existing = await _downloadForCurrentTaskSession(
if (existing == null || globalKey,
existing.bgTaskId != taskId || taskId,
existing.status != DownloadStatus.downloading.index || event: 'failure',
existing.status == DownloadStatus.completed.index || requiredStatus: DownloadStatus.downloading,
existing.status == DownloadStatus.cancelled.index) { );
appLogger.d('Ignoring stale failure for inactive download $globalKey'); if (existing == null) return;
return;
}
_cancelDownloadTimers(globalKey); _cancelDownloadTimers(globalKey);
_pendingDownloadContext.remove(globalKey); _pendingDownloadContext.remove(globalKey);
final retryCount = existing.retryCount; final retryCount = existing.retryCount;
@@ -1629,15 +1636,13 @@ class DownloadManagerService {
return; return;
} }
final existing = await _database.getDownloadedMedia(globalKey); final existing = await _downloadForCurrentTaskSession(
if (existing == null || globalKey,
existing.bgTaskId != taskId || taskId,
existing.status != DownloadStatus.downloading.index || event: 'permanent failure',
existing.status == DownloadStatus.completed.index || requiredStatus: DownloadStatus.downloading,
existing.status == DownloadStatus.cancelled.index) { );
appLogger.d('Ignoring stale permanent failure for inactive download $globalKey'); if (existing == null) return;
return;
}
_cancelDownloadTimers(globalKey); _cancelDownloadTimers(globalKey);
_pendingDownloadContext.remove(globalKey); _pendingDownloadContext.remove(globalKey);
@@ -1668,9 +1673,7 @@ class DownloadManagerService {
appLogger.i('Auto-retrying download for $globalKey'); appLogger.i('Auto-retrying download for $globalKey');
await _cleanupStaleDownload(globalKey); await _cleanupStaleDownload(globalKey);
await _transitionStatus(globalKey, DownloadStatus.queued); await _requeueDownload(globalKey, fallbackClient: client);
await _database.addToQueue(mediaGlobalKey: globalKey);
unawaited(_processQueue(client));
} }
/// Handle a completed video download — store path, download supplementary content, mark done. /// Handle a completed video download — store path, download supplementary content, mark done.
@@ -1684,18 +1687,16 @@ class DownloadManagerService {
_completingKeys.add(globalKey); _completingKeys.add(globalKey);
try { try {
// Fresh DB check — bail if already completed (guards against race with orphan scan) // Fresh DB check — bail if already completed (guards against race with orphan scan)
final existingCheck = await _database.getDownloadedMedia(globalKey); final existingCheck = await _downloadForCurrentTaskSession(
globalKey,
task.taskId,
event: 'completion',
requiredStatus: DownloadStatus.downloading,
);
if (_cancellingKeys.contains(globalKey) || existingCheck == null) { if (_cancellingKeys.contains(globalKey) || existingCheck == null) {
appLogger.d('Download no longer active for $globalKey, skipping completion'); appLogger.d('Download no longer active for $globalKey, skipping completion');
return; return;
} }
if (existingCheck.bgTaskId != task.taskId || existingCheck.status != DownloadStatus.downloading.index) {
appLogger.d(
'Ignoring stale completion for $globalKey from task ${task.taskId} '
'(current task: ${existingCheck.bgTaskId ?? 'none'}, status: ${existingCheck.status})',
);
return;
}
// Flush any pending debounced progress write + cancel any scheduled retry. // Flush any pending debounced progress write + cancel any scheduled retry.
_cancelDownloadTimers(globalKey); _cancelDownloadTimers(globalKey);
@@ -2071,10 +2072,7 @@ class DownloadManagerService {
// Native resume failed or not supported (SAF mode) — re-enqueue from scratch // Native resume failed or not supported (SAF mode) — re-enqueue from scratch
await _cleanupStaleDownload(globalKey); await _cleanupStaleDownload(globalKey);
await _transitionStatus(globalKey, DownloadStatus.queued); await _requeueDownload(globalKey, fallbackClient: client);
await _database.addToQueue(mediaGlobalKey: globalKey);
final resolvedClient = await _getClientForDownloadKey(globalKey) ?? client;
unawaited(_processQueue(resolvedClient));
} }
Future<bool> _tryResumeNativeTask( Future<bool> _tryResumeNativeTask(
@@ -2121,10 +2119,7 @@ class DownloadManagerService {
_autoRetryTimers.remove(globalKey)?.cancel(); _autoRetryTimers.remove(globalKey)?.cancel();
await _cleanupStaleDownload(globalKey); await _cleanupStaleDownload(globalKey);
await _database.clearDownloadError(globalKey); await _database.clearDownloadError(globalKey);
await _transitionStatus(globalKey, DownloadStatus.queued); await _requeueDownload(globalKey, fallbackClient: client);
await _database.addToQueue(mediaGlobalKey: globalKey);
final resolvedClient = await _getClientForDownloadKey(globalKey) ?? client;
unawaited(_processQueue(resolvedClient));
} }
/// Cancel a download /// Cancel a download
+5 -1
View File
@@ -1452,7 +1452,11 @@ void main() {
await expectLater(p.queueDownload(season, _ThrowingClient()), throwsA(isA<StateError>())); await expectLater(p.queueDownload(season, _ThrowingClient()), throwsA(isA<StateError>()));
expect(p.getMetadata('srv:7'), isNotNull, reason: 'pre-existing metadata must survive rollback'); expect(
p.getMetadata('srv:7')?.title,
'Original Title',
reason: 'queue rollback must restore the previous value, not leave the temporary stash',
);
p.dispose(); p.dispose();
}); });
@@ -47,6 +47,30 @@ void main() {
}); });
}); });
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', () { group('artworkStorageKey', () {
test('removes Jellyfin api_key from persisted artwork keys', () { 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'; final url = 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=secret-token';
@@ -443,6 +467,37 @@ void main() {
expect(row?.bgTaskId, 'current-task'); 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 { test('requeues current system cancel without in-memory context', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory()); final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close); addTearDown(db.close);