-
-
Notifications
You must be signed in to change notification settings - Fork 29
[Architecture] Responsability of download client adapters #599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: canary
Are you sure you want to change the base?
Changes from all commits
b6ef9bc
b6cb634
b97809c
4d21079
0fd6574
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,8 @@ | |
| * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
| */ | ||
| using Listenarr.Application.Interfaces; | ||
| using Listenarr.Application.Interfaces.Repositories; | ||
| using Listenarr.Application.Mapping; | ||
| using Listenarr.Application.Security; | ||
| using Listenarr.Domain.Common; | ||
| using Listenarr.Domain.Models; | ||
|
|
@@ -32,31 +34,21 @@ namespace Listenarr.Application.Downloads | |
| public class DownloadClientGateway( | ||
| IRemotePathMappingService remotePathMappingService, | ||
| IDownloadClientAdapterFactory factory, | ||
| IDownloadRepository downloadRepository, | ||
| ILogger<DownloadClientGateway> logger) : IDownloadClientGateway | ||
| { | ||
| internal IDownloadClientAdapter ResolveAdapter(DownloadClientConfiguration client) | ||
| { | ||
| if (client == null) | ||
| { | ||
| throw new ArgumentNullException(nameof(client)); | ||
| } | ||
| ArgumentNullException.ThrowIfNull(client); | ||
|
|
||
| var attemptedKeys = new List<string?> { client.Id, client.Type }; | ||
| foreach (var key in attemptedKeys) | ||
| if (!string.IsNullOrWhiteSpace(client.Type)) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(key)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| return factory.GetByIdOrType(key); | ||
| return factory.GetByType(client.Type); | ||
| } | ||
| catch (InvalidOperationException) | ||
| { | ||
| // Try the next key. | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -90,21 +82,13 @@ public Task<bool> RemoveAsync(DownloadClientConfiguration client, string id, boo | |
|
|
||
| public async Task<List<QueueItem>> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) | ||
| { | ||
| var adapter = ResolveAdapter(client); | ||
| var results = await adapter.GetQueueAsync(client, ct); | ||
|
|
||
| List<QueueItem> translatedResults = []; | ||
| foreach (QueueItem result in results) | ||
| { | ||
| translatedResults.Add(await TranslateQueueItemPathsAsync(client, result)); | ||
| } | ||
| return translatedResults; | ||
| } | ||
| var downloads = await downloadRepository.GetByClientAsync(client.Id); | ||
| var ids = GetExternalIds(downloads); | ||
|
|
||
| public Task<List<(string Id, string Name)>> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) | ||
| { | ||
| var adapter = ResolveAdapter(client); | ||
| return adapter.GetRecentHistoryAsync(client, limit, ct); | ||
| var items = await adapter.GetQueueAsync(client, ids, ct); | ||
| var tasks = items.Select(item => TranslateQueueItemPathsAsync(client, item)); | ||
| return [.. await Task.WhenAll(tasks)]; | ||
| } | ||
|
|
||
| public async Task<bool> MarkItemAsImportedAsync(DownloadClientConfiguration client, Download download, CancellationToken ct = default) | ||
|
|
@@ -131,6 +115,48 @@ public async Task<QueueItem> GetQueueItemAsync( | |
| return await TranslateQueueItemPathsAsync(client, item); | ||
| } | ||
|
|
||
| public async Task<List<Download>> FetchDownloadsAsync(DownloadClientConfiguration client, List<Download> downloads, CancellationToken ct = default) | ||
| { | ||
| var ids = GetExternalIds(downloads); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This path now only polls downloads that already have a stored external ID. If a legacy (or hopefully not a new record) is missing ClientDownloadId/TorrentHash, the adapter gets an empty ID list and the download can stay stuck without progress updates. Probably a reasonable tradeoff though, but maybe we should include a view for users to see "orphaned" records? wdyt
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe other *arr silently remove the download in case it becomes orphaned (no external ID or external ID no longer found in the download client), this seems like a good idea as in that case, automatic search will probably pick it up again later or the user can re-start a manual search (audiobook would be shown in the wanted list with no active download on it). I can see an issue with removing orphaned download: If the adapter is no longer reachable or experience technical issues, then it will send an empty list of queue items and we may delete them by error in those case. We can mitigate that by always checking if the adapter is up and running (using the TestAdapter method) first |
||
|
|
||
| var adapter = ResolveAdapter(client); | ||
| var items = await adapter.GetQueueAsync(client, ids!, ct); | ||
| var tasks = items.Select(item => TranslateQueueItemPathsAsync(client, item)); | ||
| items = [.. await Task.WhenAll(tasks)]; | ||
|
|
||
| foreach (QueueItem item in items) | ||
| { | ||
| var download = downloads.FirstOrDefault(d => d.GetExternalId() == item.Id); | ||
| if (download == null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| logger.LogDebug($"Found matching qBittorrent torrent for {download.Id}: {item.Title} (Hash: {item.Id}, Status: {item.Status}, Progress: {item.Progress:P2}, LocalPath: {item.LocalPath}, ContentPath: {item.ContentPath})"); | ||
|
|
||
| // DIAGNOSTIC: Log detailed completion check values | ||
| logger.LogInformation($"Completion diagnostic for {download.Id}: Progress={item.Progress:F4} (>= 1.0? {item.Progress >= 1.0}), AmountLeft={item.Size - item.Downloaded} (== 0? {item.Size - item.Downloaded <= 0}), Status={item.Status}"); | ||
|
|
||
| download = QueueItemConverter.UpdateFromQueueItem(download, item); | ||
| } | ||
|
|
||
| return downloads; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Give the list of external IDs from a list of download | ||
| /// </summary> | ||
| /// <param name="downloads"></param> | ||
| /// <returns></returns> | ||
| private List<string> GetExternalIds(List<Download> downloads) | ||
| { | ||
| return downloads | ||
| .Select(d => d.GetExternalId()) | ||
| .Where(id => id != null) | ||
| .ToHashSet() | ||
| .ToList()!; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Handles path mapping of queue item | ||
| /// Make sure all path are localy accessible after processing and | ||
|
|
@@ -201,16 +227,5 @@ private async Task<QueueItem> TranslateQueueItemPathsAsync(DownloadClientConfigu | |
|
|
||
| return item; | ||
| } | ||
|
|
||
| public async Task<List<Download>> FetchDownloadsAsync(DownloadClientConfiguration client, List<Download> downloads, CancellationToken ct = default) | ||
| { | ||
| var adapter = ResolveAdapter(client); | ||
| downloads = await adapter.FetchDownloadsAsync(client, downloads, ct); | ||
| foreach (Download download in downloads) | ||
| { | ||
| download.DownloadPath = await remotePathMappingService.TranslatePathAsync(client, download.DownloadPath); | ||
| } | ||
| return downloads; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this changes the queue endpoint fetching the client queue to fetching only downloads already tracked in our DB. That drops untracked queue items and completed external downloads before DownloadQueueService can reconcile or display them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My reasoning is the following: If all adapters have to return an external ID when we add a download: It means that every Listenarr Download has an external ID. So, any Download without an external ID should be dropped and any queue item without external ID should be ignored.
By fetching the whole queue and deciding what to use or not in the application, we gather informations that are not related to Listenarr and should probably never leave the adapter layer (by design, it solves some issues I witnessed where all queue items are shown for an adapter which is not expected)