Remove stalled downloads (#21)
This commit is contained in:
@@ -8,9 +8,19 @@ The tool supports both qBittorrent's built-in exclusion features and its own blo
|
|||||||
|
|
||||||
Refer to the [Environment variables](#Environment-variables) section for detailed configuration instructions and the [Setup](#Setup) section for an in-depth explanation of the cleanup process.
|
Refer to the [Environment variables](#Environment-variables) section for detailed configuration instructions and the [Setup](#Setup) section for an in-depth explanation of the cleanup process.
|
||||||
|
|
||||||
|
## Key features
|
||||||
|
- Marks unwanted files as skip/unwanted in the download client.
|
||||||
|
- Automatically strikes stalled or stuck downloads.
|
||||||
|
- Removes and blocks downloads that reached the maximum number of strikes or are marked as unwanted by the download client or by cleanuperr and triggers a search for removed downloads.
|
||||||
|
|
||||||
## Important note
|
## Important note
|
||||||
|
|
||||||
Only the **latest versions** of qBittorrent, Deluge, Sonarr etc. are supported, or earlier versions that have the same API as the latest version.
|
Only the **latest versions** of the following apps are supported, or earlier versions that have the same API as the latest version:
|
||||||
|
- qBittorrent
|
||||||
|
- Deluge
|
||||||
|
- Transmission
|
||||||
|
- Sonarr
|
||||||
|
- Radarr
|
||||||
|
|
||||||
This tool is actively developed and still a work in progress. Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together:
|
This tool is actively developed and still a work in progress. Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together:
|
||||||
|
|
||||||
@@ -28,12 +38,14 @@ This tool is actively developed and still a work in progress. Join the Discord s
|
|||||||
2. **Queue cleaner** will:
|
2. **Queue cleaner** will:
|
||||||
- Run every 5 minutes (or configured cron).
|
- Run every 5 minutes (or configured cron).
|
||||||
- Process all items in the *arr queue.
|
- Process all items in the *arr queue.
|
||||||
|
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in matadata downloading** or **failed to be imported**.
|
||||||
|
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.
|
||||||
- Check each queue item if it meets one of the following condition in the download client:
|
- Check each queue item if it meets one of the following condition in the download client:
|
||||||
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**).
|
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**).
|
||||||
- All associated files of are marked as **unwanted/skipped**.
|
- All associated files of are marked as **unwanted/skipped**.
|
||||||
- If the item **DOES NOT** match the above criteria, it will be skipped.
|
- If the item **DOES NOT** match the above criteria, it will be skipped.
|
||||||
- If the item **DOES** match the criteria:
|
- If the item **DOES** match the criteria or has received the **maximum number of strikes**:
|
||||||
- It will be removed from the *arr's queue.
|
- It will be removed from the *arr's queue and blocked.
|
||||||
- It will be deleted from the download client.
|
- It will be deleted from the download client.
|
||||||
- A new search will be triggered for the *arr item.
|
- A new search will be triggered for the *arr item.
|
||||||
|
|
||||||
@@ -78,6 +90,8 @@ services:
|
|||||||
|
|
||||||
- QUEUECLEANER__ENABLED=true
|
- QUEUECLEANER__ENABLED=true
|
||||||
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
||||||
|
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
|
||||||
|
- QUEUECLEANER__STALLED_MAX_STRIKES=5
|
||||||
|
|
||||||
- CONTENTBLOCKER__ENABLED=true
|
- CONTENTBLOCKER__ENABLED=true
|
||||||
- CONTENTBLOCKER__BLACKLIST__ENABLED=true
|
- CONTENTBLOCKER__BLACKLIST__ENABLED=true
|
||||||
@@ -91,21 +105,25 @@ services:
|
|||||||
- QBITTORRENT__USERNAME=user
|
- QBITTORRENT__USERNAME=user
|
||||||
- QBITTORRENT__PASSWORD=pass
|
- QBITTORRENT__PASSWORD=pass
|
||||||
# OR
|
# OR
|
||||||
|
# - DOWNLOAD_CLIENT=deluge
|
||||||
# - DELUGE__URL=http://localhost:8112
|
# - DELUGE__URL=http://localhost:8112
|
||||||
# - DELUGE__PASSWORD=testing
|
# - DELUGE__PASSWORD=testing
|
||||||
# OR
|
# OR
|
||||||
|
# - DOWNLOAD_CLIENT=transmission
|
||||||
# - TRANSMISSION__URL=http://localhost:9091
|
# - TRANSMISSION__URL=http://localhost:9091
|
||||||
# - TRANSMISSION__USERNAME=test
|
# - TRANSMISSION__USERNAME=test
|
||||||
# - TRANSMISSION__PASSWORD=testing
|
# - TRANSMISSION__PASSWORD=testing
|
||||||
|
|
||||||
- SONARR__ENABLED=true
|
- SONARR__ENABLED=true
|
||||||
- SONARR__SEARCHTYPE=Episode
|
- SONARR__SEARCHTYPE=Episode
|
||||||
|
- SONARR__STALLED_MAX_STRIKES=5
|
||||||
- SONARR__INSTANCES__0__URL=http://localhost:8989
|
- SONARR__INSTANCES__0__URL=http://localhost:8989
|
||||||
- SONARR__INSTANCES__0__APIKEY=secret1
|
- SONARR__INSTANCES__0__APIKEY=secret1
|
||||||
- SONARR__INSTANCES__1__URL=http://localhost:8990
|
- SONARR__INSTANCES__1__URL=http://localhost:8990
|
||||||
- SONARR__INSTANCES__1__APIKEY=secret2
|
- SONARR__INSTANCES__1__APIKEY=secret2
|
||||||
|
|
||||||
- RADARR__ENABLED=true
|
- RADARR__ENABLED=true
|
||||||
|
- RADARR__STALLED_MAX_STRIKES=5
|
||||||
- RADARR__INSTANCES__0__URL=http://localhost:7878
|
- RADARR__INSTANCES__0__URL=http://localhost:7878
|
||||||
- RADARR__INSTANCES__0__APIKEY=secret3
|
- RADARR__INSTANCES__0__APIKEY=secret3
|
||||||
- RADARR__INSTANCES__1__URL=http://localhost:7879
|
- RADARR__INSTANCES__1__URL=http://localhost:7879
|
||||||
@@ -123,11 +141,13 @@ services:
|
|||||||
| LOGGING__FILE__PATH | No | Directory where to save the log files | empty |
|
| LOGGING__FILE__PATH | No | Directory where to save the log files | empty |
|
||||||
| LOGGING__ENHANCED | No | Enhance logs whenever possible<br>A more detailed description is provided [here](variables.md#LOGGING__ENHANCED) | true |
|
| LOGGING__ENHANCED | No | Enhance logs whenever possible<br>A more detailed description is provided [here](variables.md#LOGGING__ENHANCED) | true |
|
||||||
|||||
|
|||||
|
||||||
| TRIGGERS__QUEUECLEANER | Yes if queue cleaner is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html) | 0 0/5 * * * ? |
|
| TRIGGERS__QUEUECLEANER | Yes if queue cleaner is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html)<br>Can be a max of 1h interval | 0 0/5 * * * ? |
|
||||||
| TRIGGERS__CONTENTBLOCKER | Yes if content blocker is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html) | 0 0/5 * * * ? |
|
| TRIGGERS__CONTENTBLOCKER | Yes if content blocker is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html)<br>Can be a max of 1h interval | 0 0/5 * * * ? |
|
||||||
|||||
|
|||||
|
||||||
| QUEUECLEANER__ENABLED | No | Enable or disable the queue cleaner | true |
|
| QUEUECLEANER__ENABLED | No | Enable or disable the queue cleaner | true |
|
||||||
| QUEUECLEANER__RUNSEQUENTIALLY | No | If set to true, the queue cleaner will run after the content blocker instead of running in parallel, streamlining the cleaning process | true |
|
| QUEUECLEANER__RUNSEQUENTIALLY | No | If set to true, the queue cleaner will run after the content blocker instead of running in parallel, streamlining the cleaning process | true |
|
||||||
|
| QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES | No | After how many strikes should a failed import be removed<br>0 means never | 0 |
|
||||||
|
| QUEUECLEANER__STALLED_MAX_STRIKES | No | After how many strikes should a stalled download be removed<br>0 means never | 0 |
|
||||||
|||||
|
|||||
|
||||||
| CONTENTBLOCKER__ENABLED | No | Enable or disable the content blocker | false |
|
| CONTENTBLOCKER__ENABLED | No | Enable or disable the content blocker | false |
|
||||||
| CONTENTBLOCKER__BLACKLIST__ENABLED | Yes if content blocker is enabled and whitelist is not enabled | Enable or disable the blacklist | false |
|
| CONTENTBLOCKER__BLACKLIST__ENABLED | Yes if content blocker is enabled and whitelist is not enabled | Enable or disable the blacklist | false |
|
||||||
@@ -149,12 +169,12 @@ services:
|
|||||||
|||||
|
|||||
|
||||||
| SONARR__ENABLED | No | Enable or disable Sonarr cleanup | true |
|
| SONARR__ENABLED | No | Enable or disable Sonarr cleanup | true |
|
||||||
| SONARR__SEARCHTYPE | No | What to search for after removing a queue item<br>Can be `Episode`, `Season` or `Series` | `Episode` |
|
| SONARR__SEARCHTYPE | No | What to search for after removing a queue item<br>Can be `Episode`, `Season` or `Series` | `Episode` |
|
||||||
| SONARR__INSTANCES__0__URL | Yes | First Sonarr instance url | http://localhost:8989 |
|
| SONARR__INSTANCES__0__URL | No | First Sonarr instance url | http://localhost:8989 |
|
||||||
| SONARR__INSTANCES__0__APIKEY | Yes | First Sonarr instance API key | empty |
|
| SONARR__INSTANCES__0__APIKEY | No | First Sonarr instance API key | empty |
|
||||||
|||||
|
|||||
|
||||||
| RADARR__ENABLED | No | Enable or disable Radarr cleanup | false |
|
| RADARR__ENABLED | No | Enable or disable Radarr cleanup | false |
|
||||||
| RADARR__INSTANCES__0__URL | Yes | First Radarr instance url | http://localhost:8989 |
|
| RADARR__INSTANCES__0__URL | No | First Radarr instance url | http://localhost:8989 |
|
||||||
| RADARR__INSTANCES__0__APIKEY | Yes | First Radarr instance API key | empty |
|
| RADARR__INSTANCES__0__APIKEY | No | First Radarr instance API key | empty |
|
||||||
|
|
||||||
#
|
#
|
||||||
### To be noted
|
### To be noted
|
||||||
@@ -187,3 +207,9 @@ SONARR__INSTANCES__<NUMBER>__APIKEY
|
|||||||
### Run as a Windows Service
|
### Run as a Windows Service
|
||||||
|
|
||||||
Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678
|
Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
Special thanks for inspiration go to:
|
||||||
|
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||||
|
- [ManiMatter/decluttarr](https://github.com/ManiMatter/decluttarr)
|
||||||
|
- [PaeyMoopy/sonarr-radarr-queue-cleaner](https://github.com/PaeyMoopy/sonarr-radarr-queue-cleaner)
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Serilog" Version="4.1.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Common.Configuration.Arr;
|
namespace Common.Configuration.Arr;
|
||||||
|
|
||||||
public abstract record ArrConfig
|
public abstract record ArrConfig
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Common.Configuration.QueueCleaner;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace Common.Configuration.QueueCleaner;
|
||||||
|
|
||||||
public sealed record QueueCleanerConfig : IJobConfig
|
public sealed record QueueCleanerConfig : IJobConfig
|
||||||
{
|
{
|
||||||
@@ -8,6 +10,12 @@ public sealed record QueueCleanerConfig : IJobConfig
|
|||||||
|
|
||||||
public required bool RunSequentially { get; init; }
|
public required bool RunSequentially { get; init; }
|
||||||
|
|
||||||
|
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
|
||||||
|
public ushort ImportFailedMaxStrikes { get; init; }
|
||||||
|
|
||||||
|
[ConfigurationKeyName("STALLED_MAX_STRIKES")]
|
||||||
|
public ushort StalledMaxStrikes { get; init; }
|
||||||
|
|
||||||
public void Validate()
|
public void Validate()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Common\Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Domain.Enums;
|
||||||
|
|
||||||
|
public enum StrikeType
|
||||||
|
{
|
||||||
|
Stalled,
|
||||||
|
ImportFailed
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Domain.Arr.Queue;
|
namespace Domain.Models.Arr.Queue;
|
||||||
|
|
||||||
public record QueueListResponse
|
public record QueueListResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Domain.Arr.Queue;
|
namespace Domain.Models.Arr.Queue;
|
||||||
|
|
||||||
public record QueueRecord
|
public record QueueRecord
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
namespace Domain.Models.Arr;
|
using Common.Configuration.Arr;
|
||||||
|
|
||||||
|
namespace Domain.Models.Arr;
|
||||||
|
|
||||||
public sealed class SonarrSearchItem : SearchItem
|
public sealed class SonarrSearchItem : SearchItem
|
||||||
{
|
{
|
||||||
public long SeriesId { get; set; }
|
public long SeriesId { get; set; }
|
||||||
|
|
||||||
|
public SonarrSearchType SearchType { get; set; }
|
||||||
|
|
||||||
public override bool Equals(object? obj)
|
public override bool Equals(object? obj)
|
||||||
{
|
{
|
||||||
if (obj is not SonarrSearchItem other)
|
if (obj is not SonarrSearchItem other)
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Domain.Models.Deluge.Response;
|
|
||||||
|
|
||||||
public sealed record DelugeMinimalStatus
|
|
||||||
{
|
|
||||||
public string? Hash { get; set; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Domain.Models.Deluge.Response;
|
||||||
|
|
||||||
|
public sealed record TorrentStatus
|
||||||
|
{
|
||||||
|
public string? Hash { get; set; }
|
||||||
|
|
||||||
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public ulong Eta { get; set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Domain.Models.Sonarr;
|
using Common.Configuration.Arr;
|
||||||
|
|
||||||
|
namespace Domain.Models.Sonarr;
|
||||||
|
|
||||||
public sealed record SonarrCommand
|
public sealed record SonarrCommand
|
||||||
{
|
{
|
||||||
@@ -9,4 +11,6 @@ public sealed record SonarrCommand
|
|||||||
public long? SeasonNumber { get; set; }
|
public long? SeasonNumber { get; set; }
|
||||||
|
|
||||||
public List<long>? EpisodeIds { get; set; }
|
public List<long>? EpisodeIds { get; set; }
|
||||||
|
|
||||||
|
public SonarrSearchType SearchType { get; set; }
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ using Common.Configuration.Arr;
|
|||||||
using Common.Configuration.ContentBlocker;
|
using Common.Configuration.ContentBlocker;
|
||||||
using Common.Configuration.DownloadClient;
|
using Common.Configuration.DownloadClient;
|
||||||
using Common.Configuration.Logging;
|
using Common.Configuration.Logging;
|
||||||
|
using Common.Configuration.QueueCleaner;
|
||||||
using Domain.Enums;
|
using Domain.Enums;
|
||||||
|
|
||||||
namespace Executable.DependencyInjection;
|
namespace Executable.DependencyInjection;
|
||||||
@@ -11,6 +12,7 @@ public static class ConfigurationDI
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
||||||
services
|
services
|
||||||
|
.Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName))
|
||||||
.Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName))
|
.Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName))
|
||||||
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
|
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
|
||||||
.Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName))
|
.Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName))
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public static class LoggingDI
|
|||||||
Log.Logger = logConfig
|
Log.Logger = logConfig
|
||||||
.MinimumLevel.Is(level)
|
.MinimumLevel.Is(level)
|
||||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||||
|
.MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning)
|
||||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||||
.WriteTo.Console(new ExpressionTemplate(consoleOutputTemplate.Replace("PAD", padding.ToString())))
|
.WriteTo.Console(new ExpressionTemplate(consoleOutputTemplate.Replace("PAD", padding.ToString())))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class MainDI
|
|||||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||||
.AddHttpClients()
|
.AddHttpClients()
|
||||||
.AddConfiguration(configuration)
|
.AddConfiguration(configuration)
|
||||||
|
.AddMemoryCache()
|
||||||
.AddServices()
|
.AddServices()
|
||||||
.AddQuartzServices(configuration);
|
.AddQuartzServices(configuration);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Infrastructure.Verticals.ContentBlocker;
|
|||||||
using Infrastructure.Verticals.Jobs;
|
using Infrastructure.Verticals.Jobs;
|
||||||
using Infrastructure.Verticals.QueueCleaner;
|
using Infrastructure.Verticals.QueueCleaner;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
using Quartz.Spi;
|
||||||
|
|
||||||
namespace Executable.DependencyInjection;
|
namespace Executable.DependencyInjection;
|
||||||
|
|
||||||
@@ -95,6 +96,19 @@ public static class QuartzDI
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var triggerObj = (IOperableTrigger)TriggerBuilder.Create()
|
||||||
|
.WithIdentity("ExampleTrigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithCronSchedule(trigger)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
|
||||||
|
|
||||||
|
if (nextFireTimes[1] - nextFireTimes[0] > TimeSpan.FromHours(1))
|
||||||
|
{
|
||||||
|
throw new Exception($"{trigger} should have a fire time of maximum 1 hour");
|
||||||
|
}
|
||||||
|
|
||||||
q.AddTrigger(opts =>
|
q.AddTrigger(opts =>
|
||||||
{
|
{
|
||||||
opts.ForJob(typeName)
|
opts.ForJob(typeName)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
using Executable.Jobs;
|
using Infrastructure.Verticals.Arr;
|
||||||
using Infrastructure.Verticals.Arr;
|
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
using Infrastructure.Verticals.DownloadClient;
|
using Infrastructure.Verticals.DownloadClient;
|
||||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||||
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.QueueCleaner;
|
using Infrastructure.Verticals.QueueCleaner;
|
||||||
|
|
||||||
namespace Executable.DependencyInjection;
|
namespace Executable.DependencyInjection;
|
||||||
@@ -23,5 +23,6 @@ public static class ServicesDI
|
|||||||
.AddTransient<TransmissionService>()
|
.AddTransient<TransmissionService>()
|
||||||
.AddTransient<ArrQueueIterator>()
|
.AddTransient<ArrQueueIterator>()
|
||||||
.AddTransient<DownloadServiceFactory>()
|
.AddTransient<DownloadServiceFactory>()
|
||||||
.AddSingleton<BlocklistProvider>();
|
.AddSingleton<BlocklistProvider>()
|
||||||
|
.AddSingleton<Striker>();
|
||||||
}
|
}
|
||||||
@@ -9,15 +9,15 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
|
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
|
||||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.1.0" />
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ public sealed class GenericJob<T> : IJob
|
|||||||
private readonly ILogger<GenericJob<T>> _logger;
|
private readonly ILogger<GenericJob<T>> _logger;
|
||||||
private readonly T _handler;
|
private readonly T _handler;
|
||||||
|
|
||||||
|
|
||||||
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
|
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
},
|
},
|
||||||
"QueueCleaner": {
|
"QueueCleaner": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"RunSequentially": true
|
"RunSequentially": true,
|
||||||
|
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||||
|
"STALLED_MAX_STRIKES": 5
|
||||||
},
|
},
|
||||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||||
"qBittorrent": {
|
"qBittorrent": {
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
},
|
},
|
||||||
"QueueCleaner": {
|
"QueueCleaner": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"RunSequentially": true
|
"RunSequentially": true,
|
||||||
|
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||||
|
"STALLED_MAX_STRIKES": 5
|
||||||
},
|
},
|
||||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||||
"qBittorrent": {
|
"qBittorrent": {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FLM.Transmission" Version="1.0.0" />
|
<PackageReference Include="FLM.Transmission" Version="1.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||||
<PackageReference Include="QBittorrent.Client" Version="1.9.24285.1" />
|
<PackageReference Include="QBittorrent.Client" Version="1.9.24285.1" />
|
||||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using Common.Configuration;
|
using Common.Configuration.Arr;
|
||||||
using Common.Configuration.Arr;
|
|
||||||
using Common.Configuration.Logging;
|
using Common.Configuration.Logging;
|
||||||
using Domain.Arr.Queue;
|
using Common.Configuration.QueueCleaner;
|
||||||
|
using Domain.Enums;
|
||||||
using Domain.Models.Arr;
|
using Domain.Models.Arr;
|
||||||
|
using Domain.Models.Arr.Queue;
|
||||||
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@@ -14,17 +16,28 @@ public abstract class ArrClient
|
|||||||
protected readonly ILogger<ArrClient> _logger;
|
protected readonly ILogger<ArrClient> _logger;
|
||||||
protected readonly HttpClient _httpClient;
|
protected readonly HttpClient _httpClient;
|
||||||
protected readonly LoggingConfig _loggingConfig;
|
protected readonly LoggingConfig _loggingConfig;
|
||||||
|
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||||
|
protected readonly Striker _striker;
|
||||||
|
|
||||||
protected ArrClient(ILogger<ArrClient> logger, IHttpClientFactory httpClientFactory, IOptions<LoggingConfig> loggingConfig)
|
protected ArrClient(
|
||||||
|
ILogger<ArrClient> logger,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IOptions<LoggingConfig> loggingConfig,
|
||||||
|
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||||
|
Striker striker
|
||||||
|
)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_striker = striker;
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_loggingConfig = loggingConfig.Value;
|
_loggingConfig = loggingConfig.Value;
|
||||||
|
_queueCleanerConfig = queueCleanerConfig.Value;
|
||||||
|
_striker = striker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
|
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
|
||||||
{
|
{
|
||||||
Uri uri = new(arrInstance.Url, $"/api/v3/queue?page={page}&pageSize=200&sortKey=timeleft");
|
Uri uri = new(arrInstance.Url, GetQueueUrlPath(page));
|
||||||
|
|
||||||
using HttpRequestMessage request = new(HttpMethod.Get, uri);
|
using HttpRequestMessage request = new(HttpMethod.Get, uri);
|
||||||
SetApiKey(request, arrInstance.ApiKey);
|
SetApiKey(request, arrInstance.ApiKey);
|
||||||
@@ -52,6 +65,28 @@ public abstract class ArrClient
|
|||||||
return queueResponse;
|
return queueResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public virtual bool ShouldRemoveFromQueue(QueueRecord record)
|
||||||
|
{
|
||||||
|
bool hasWarn() => record.TrackedDownloadStatus
|
||||||
|
.Equals("warning", StringComparison.InvariantCultureIgnoreCase);
|
||||||
|
bool isImportBlocked() => record.TrackedDownloadState
|
||||||
|
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
|
||||||
|
bool isImportPending() => record.TrackedDownloadState
|
||||||
|
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
|
||||||
|
|
||||||
|
if (hasWarn() && (isImportBlocked() || isImportPending()))
|
||||||
|
{
|
||||||
|
return _striker.StrikeAndCheckLimit(
|
||||||
|
record.DownloadId,
|
||||||
|
record.Title,
|
||||||
|
_queueCleanerConfig.ImportFailedMaxStrikes,
|
||||||
|
StrikeType.ImportFailed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord queueRecord)
|
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord queueRecord)
|
||||||
{
|
{
|
||||||
Uri uri = new(arrInstance.Url, $"/api/v3/queue/{queueRecord.Id}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false");
|
Uri uri = new(arrInstance.Url, $"/api/v3/queue/{queueRecord.Id}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false");
|
||||||
@@ -76,6 +111,25 @@ public abstract class ArrClient
|
|||||||
|
|
||||||
public abstract Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items);
|
public abstract Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items);
|
||||||
|
|
||||||
|
public virtual bool IsRecordValid(QueueRecord record)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(record.DownloadId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.DownloadId.Equals(record.Title, StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("skip | item is not ready yet | {title}", record.Title);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract string GetQueueUrlPath(int page);
|
||||||
|
|
||||||
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
|
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
|
||||||
{
|
{
|
||||||
request.Headers.Add("x-api-key", apiKey);
|
request.Headers.Add("x-api-key", apiKey);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using Common.Configuration;
|
using Common.Configuration;
|
||||||
using Common.Configuration.Arr;
|
using Common.Configuration.Arr;
|
||||||
using Domain.Arr.Queue;
|
using Domain.Models.Arr.Queue;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Infrastructure.Verticals.Arr;
|
namespace Infrastructure.Verticals.Arr;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Common.Configuration.Arr;
|
using Common.Configuration.Arr;
|
||||||
using Common.Configuration.Logging;
|
using Common.Configuration.Logging;
|
||||||
|
using Common.Configuration.QueueCleaner;
|
||||||
using Domain.Models.Arr;
|
using Domain.Models.Arr;
|
||||||
|
using Domain.Models.Arr.Queue;
|
||||||
using Domain.Models.Radarr;
|
using Domain.Models.Radarr;
|
||||||
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@@ -14,11 +18,18 @@ public sealed class RadarrClient : ArrClient
|
|||||||
public RadarrClient(
|
public RadarrClient(
|
||||||
ILogger<ArrClient> logger,
|
ILogger<ArrClient> logger,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<LoggingConfig> loggingConfig
|
IOptions<LoggingConfig> loggingConfig,
|
||||||
) : base(logger, httpClientFactory, loggingConfig)
|
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||||
|
Striker striker
|
||||||
|
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override string GetQueueUrlPath(int page)
|
||||||
|
{
|
||||||
|
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
|
||||||
|
}
|
||||||
|
|
||||||
public override async Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items)
|
public override async Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items)
|
||||||
{
|
{
|
||||||
if (items?.Count is null or 0)
|
if (items?.Count is null or 0)
|
||||||
@@ -59,6 +70,17 @@ public sealed class RadarrClient : ArrClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override bool IsRecordValid(QueueRecord record)
|
||||||
|
{
|
||||||
|
if (record.MovieId is 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("skip | item information missing | {title}", record.Title);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.IsRecordValid(record);
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetSearchLog(Uri instanceUrl, RadarrCommand command, bool success, string? logContext)
|
private static string GetSearchLog(Uri instanceUrl, RadarrCommand command, bool success, string? logContext)
|
||||||
{
|
{
|
||||||
string status = success ? "triggered" : "failed";
|
string status = success ? "triggered" : "failed";
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Common.Configuration.Arr;
|
using Common.Configuration.Arr;
|
||||||
using Common.Configuration.Logging;
|
using Common.Configuration.Logging;
|
||||||
|
using Common.Configuration.QueueCleaner;
|
||||||
using Domain.Models.Arr;
|
using Domain.Models.Arr;
|
||||||
|
using Domain.Models.Arr.Queue;
|
||||||
using Domain.Models.Sonarr;
|
using Domain.Models.Sonarr;
|
||||||
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@@ -14,11 +18,18 @@ public sealed class SonarrClient : ArrClient
|
|||||||
public SonarrClient(
|
public SonarrClient(
|
||||||
ILogger<SonarrClient> logger,
|
ILogger<SonarrClient> logger,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<LoggingConfig> loggingConfig
|
IOptions<LoggingConfig> loggingConfig,
|
||||||
) : base(logger, httpClientFactory, loggingConfig)
|
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||||
|
Striker striker
|
||||||
|
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override string GetQueueUrlPath(int page)
|
||||||
|
{
|
||||||
|
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true";
|
||||||
|
}
|
||||||
|
|
||||||
public override async Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items)
|
public override async Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items)
|
||||||
{
|
{
|
||||||
if (items?.Count is null or 0)
|
if (items?.Count is null or 0)
|
||||||
@@ -26,11 +37,9 @@ public sealed class SonarrClient : ArrClient
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SonarrConfig sonarrConfig = (SonarrConfig)config;
|
|
||||||
|
|
||||||
Uri uri = new(arrInstance.Url, "/api/v3/command");
|
Uri uri = new(arrInstance.Url, "/api/v3/command");
|
||||||
|
|
||||||
foreach (SonarrCommand command in GetSearchCommands(sonarrConfig.SearchType, items))
|
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
|
||||||
{
|
{
|
||||||
using HttpRequestMessage request = new(HttpMethod.Post, uri);
|
using HttpRequestMessage request = new(HttpMethod.Post, uri);
|
||||||
request.Content = new StringContent(
|
request.Content = new StringContent(
|
||||||
@@ -41,22 +50,33 @@ public sealed class SonarrClient : ArrClient
|
|||||||
SetApiKey(request, arrInstance.ApiKey);
|
SetApiKey(request, arrInstance.ApiKey);
|
||||||
|
|
||||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||||
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, sonarrConfig.SearchType);
|
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
_logger.LogInformation("{log}", GetSearchLog(sonarrConfig.SearchType, arrInstance.Url, command, true, logContext));
|
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
_logger.LogError("{log}", GetSearchLog(sonarrConfig.SearchType, arrInstance.Url, command, false, logContext));
|
_logger.LogError("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, false, logContext));
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override bool IsRecordValid(QueueRecord record)
|
||||||
|
{
|
||||||
|
if (record.EpisodeId is 0 || record.SeriesId is 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("skip | item information missing | {title}", record.Title);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.IsRecordValid(record);
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetSearchLog(
|
private static string GetSearchLog(
|
||||||
SonarrSearchType searchType,
|
SonarrSearchType searchType,
|
||||||
Uri instanceUrl,
|
Uri instanceUrl,
|
||||||
@@ -191,7 +211,7 @@ public sealed class SonarrClient : ArrClient
|
|||||||
return JsonConvert.DeserializeObject<Series>(responseBody);
|
return JsonConvert.DeserializeObject<Series>(responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SonarrCommand> GetSearchCommands(SonarrSearchType searchType, HashSet<SearchItem> items)
|
private List<SonarrCommand> GetSearchCommands(HashSet<SonarrSearchItem> items)
|
||||||
{
|
{
|
||||||
const string episodeSearch = "EpisodeSearch";
|
const string episodeSearch = "EpisodeSearch";
|
||||||
const string seasonSearch = "SeasonSearch";
|
const string seasonSearch = "SeasonSearch";
|
||||||
@@ -199,13 +219,13 @@ public sealed class SonarrClient : ArrClient
|
|||||||
|
|
||||||
List<SonarrCommand> commands = new();
|
List<SonarrCommand> commands = new();
|
||||||
|
|
||||||
foreach (SearchItem item in items)
|
foreach (SonarrSearchItem item in items)
|
||||||
{
|
{
|
||||||
SonarrCommand command = searchType is SonarrSearchType.Episode
|
SonarrCommand command = item.SearchType is SonarrSearchType.Episode
|
||||||
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
|
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
|
||||||
: new();
|
: new();
|
||||||
|
|
||||||
switch (searchType)
|
switch (item.SearchType)
|
||||||
{
|
{
|
||||||
case SonarrSearchType.Episode when command.EpisodeIds is null:
|
case SonarrSearchType.Episode when command.EpisodeIds is null:
|
||||||
command.EpisodeIds = [item.Id];
|
command.EpisodeIds = [item.Id];
|
||||||
@@ -227,15 +247,16 @@ public sealed class SonarrClient : ArrClient
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null);
|
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchType is SonarrSearchType.Episode && commands.Count > 0)
|
if (item.SearchType is SonarrSearchType.Episode && commands.Count > 0)
|
||||||
{
|
{
|
||||||
// only one command will be generated for episodes search
|
// only one command will be generated for episodes search
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
command.SearchType = item.SearchType;
|
||||||
commands.Add(command);
|
commands.Add(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Common.Configuration;
|
using Common.Configuration;
|
||||||
using Common.Configuration.Arr;
|
using Common.Configuration.Arr;
|
||||||
using Domain.Arr.Queue;
|
|
||||||
using Domain.Enums;
|
using Domain.Enums;
|
||||||
|
using Domain.Models.Arr.Queue;
|
||||||
using Infrastructure.Verticals.Arr;
|
using Infrastructure.Verticals.Arr;
|
||||||
using Infrastructure.Verticals.DownloadClient;
|
using Infrastructure.Verticals.DownloadClient;
|
||||||
using Infrastructure.Verticals.Jobs;
|
using Infrastructure.Verticals.Jobs;
|
||||||
@@ -58,12 +58,4 @@ public sealed class ContentBlocker : GenericHandler
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ArrClient GetClient(InstanceType type) =>
|
|
||||||
type switch
|
|
||||||
{
|
|
||||||
InstanceType.Sonarr => _sonarrClient,
|
|
||||||
InstanceType.Radarr => _radarrClient,
|
|
||||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,46 @@
|
|||||||
using Common.Configuration;
|
|
||||||
using Common.Configuration.DownloadClient;
|
using Common.Configuration.DownloadClient;
|
||||||
|
using Common.Configuration.QueueCleaner;
|
||||||
using Domain.Models.Deluge.Response;
|
using Domain.Models.Deluge.Response;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
||||||
|
|
||||||
public sealed class DelugeService : IDownloadService
|
public sealed class DelugeService : DownloadServiceBase
|
||||||
{
|
{
|
||||||
private readonly ILogger<DelugeService> _logger;
|
|
||||||
private readonly DelugeClient _client;
|
private readonly DelugeClient _client;
|
||||||
private readonly FilenameEvaluator _filenameEvaluator;
|
|
||||||
|
|
||||||
public DelugeService(
|
public DelugeService(
|
||||||
ILogger<DelugeService> logger,
|
ILogger<DelugeService> logger,
|
||||||
IOptions<DelugeConfig> config,
|
IOptions<DelugeConfig> config,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
FilenameEvaluator filenameEvaluator
|
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||||
)
|
FilenameEvaluator filenameEvaluator,
|
||||||
|
Striker striker
|
||||||
|
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
|
||||||
config.Value.Validate();
|
config.Value.Validate();
|
||||||
_client = new (config, httpClientFactory);
|
_client = new (config, httpClientFactory);
|
||||||
_filenameEvaluator = filenameEvaluator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoginAsync()
|
public override async Task LoginAsync()
|
||||||
{
|
{
|
||||||
await _client.LoginAsync();
|
await _client.LoginAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||||
{
|
{
|
||||||
hash = hash.ToLowerInvariant();
|
hash = hash.ToLowerInvariant();
|
||||||
|
|
||||||
DelugeContents? contents = null;
|
DelugeContents? contents = null;
|
||||||
|
|
||||||
if (!await HasMinimalStatus(hash))
|
TorrentStatus? status = await GetTorrentStatus(hash);
|
||||||
|
|
||||||
|
if (status?.Hash is null)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,13 +53,7 @@ public sealed class DelugeService : IDownloadService
|
|||||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no files found, torrent might be stuck in Downloading metadata
|
bool shouldRemove = contents?.Contents?.Count > 0;
|
||||||
if (contents?.Contents?.Count is null or 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool shouldRemove = true;
|
|
||||||
|
|
||||||
ProcessFiles(contents.Contents, (_, file) =>
|
ProcessFiles(contents.Contents, (_, file) =>
|
||||||
{
|
{
|
||||||
@@ -67,15 +63,18 @@ public sealed class DelugeService : IDownloadService
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return shouldRemove;
|
return shouldRemove || IsItemStuckAndShouldRemove(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task BlockUnwantedFilesAsync(string hash)
|
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||||
{
|
{
|
||||||
hash = hash.ToLowerInvariant();
|
hash = hash.ToLowerInvariant();
|
||||||
|
|
||||||
if (!await HasMinimalStatus(hash))
|
TorrentStatus? status = await GetTorrentStatus(hash);
|
||||||
|
|
||||||
|
if (status?.Hash is null)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,21 +126,28 @@ public sealed class DelugeService : IDownloadService
|
|||||||
await _client.ChangeFilesPriority(hash, sortedPriorities);
|
await _client.ChangeFilesPriority(hash, sortedPriorities);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> HasMinimalStatus(string hash)
|
private bool IsItemStuckAndShouldRemove(TorrentStatus status)
|
||||||
{
|
{
|
||||||
DelugeMinimalStatus? status = await _client.SendRequest<DelugeMinimalStatus?>(
|
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||||
"web.get_torrent_status",
|
|
||||||
hash,
|
|
||||||
new[] { "hash" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (status?.Hash is null)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
if (status.Eta > 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StrikeAndCheckLimit(status.Hash!, status.Name!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TorrentStatus?> GetTorrentStatus(string hash)
|
||||||
|
{
|
||||||
|
return await _client.SendRequest<TorrentStatus?>(
|
||||||
|
"web.get_torrent_status",
|
||||||
|
hash,
|
||||||
|
new[] { "hash", "state", "name", "eta" }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory> contents, Action<string, DelugeFileOrDirectory> processFile)
|
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory> contents, Action<string, DelugeFileOrDirectory> processFile)
|
||||||
@@ -161,7 +167,7 @@ public sealed class DelugeService : IDownloadService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Common.Configuration.QueueCleaner;
|
||||||
|
using Domain.Enums;
|
||||||
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Infrastructure.Verticals.DownloadClient;
|
||||||
|
|
||||||
|
public abstract class DownloadServiceBase : IDownloadService
|
||||||
|
{
|
||||||
|
protected readonly ILogger<DownloadServiceBase> _logger;
|
||||||
|
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||||
|
protected readonly FilenameEvaluator _filenameEvaluator;
|
||||||
|
protected readonly Striker _striker;
|
||||||
|
|
||||||
|
protected DownloadServiceBase(
|
||||||
|
ILogger<DownloadServiceBase> logger,
|
||||||
|
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||||
|
FilenameEvaluator filenameEvaluator,
|
||||||
|
Striker striker
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_queueCleanerConfig = queueCleanerConfig.Value;
|
||||||
|
_filenameEvaluator = filenameEvaluator;
|
||||||
|
_striker = striker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract void Dispose();
|
||||||
|
|
||||||
|
public abstract Task LoginAsync();
|
||||||
|
|
||||||
|
public abstract Task<bool> ShouldRemoveFromArrQueueAsync(string hash);
|
||||||
|
|
||||||
|
public abstract Task BlockUnwantedFilesAsync(string hash);
|
||||||
|
|
||||||
|
protected bool StrikeAndCheckLimit(string hash, string itemName)
|
||||||
|
{
|
||||||
|
return _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,32 @@
|
|||||||
using Common.Configuration;
|
using Common.Configuration.DownloadClient;
|
||||||
using Common.Configuration.DownloadClient;
|
using Common.Configuration.QueueCleaner;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using QBittorrent.Client;
|
using QBittorrent.Client;
|
||||||
|
|
||||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||||
|
|
||||||
public sealed class QBitService : IDownloadService
|
public sealed class QBitService : DownloadServiceBase
|
||||||
{
|
{
|
||||||
private readonly ILogger<QBitService> _logger;
|
|
||||||
private readonly QBitConfig _config;
|
private readonly QBitConfig _config;
|
||||||
private readonly QBittorrentClient _client;
|
private readonly QBittorrentClient _client;
|
||||||
private readonly FilenameEvaluator _filenameEvaluator;
|
|
||||||
|
|
||||||
public QBitService(
|
public QBitService(
|
||||||
ILogger<QBitService> logger,
|
ILogger<QBitService> logger,
|
||||||
IOptions<QBitConfig> config,
|
IOptions<QBitConfig> config,
|
||||||
FilenameEvaluator filenameEvaluator
|
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||||
)
|
FilenameEvaluator filenameEvaluator,
|
||||||
|
Striker striker
|
||||||
|
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
|
||||||
_config = config.Value;
|
_config = config.Value;
|
||||||
_config.Validate();
|
_config.Validate();
|
||||||
_client = new(_config.Url);
|
_client = new(_config.Url);
|
||||||
_filenameEvaluator = filenameEvaluator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoginAsync()
|
public override async Task LoginAsync()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password))
|
if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password))
|
||||||
{
|
{
|
||||||
@@ -37,13 +36,14 @@ public sealed class QBitService : IDownloadService
|
|||||||
await _client.LoginAsync(_config.Username, _config.Password);
|
await _client.LoginAsync(_config.Username, _config.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||||
{
|
{
|
||||||
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
if (torrent is null)
|
if (torrent is null)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,17 +55,16 @@ public sealed class QBitService : IDownloadService
|
|||||||
|
|
||||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||||
|
|
||||||
// if no files found, torrent might be stuck in Downloading metadata
|
|
||||||
if (files?.Count is null or 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if all files are marked as skip
|
// if all files are marked as skip
|
||||||
return files.All(x => x.Priority is TorrentContentPriority.Skip);
|
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task BlockUnwantedFilesAsync(string hash)
|
return IsItemStuckAndShouldRemove(torrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||||
{
|
{
|
||||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||||
|
|
||||||
@@ -91,8 +90,20 @@ public sealed class QBitService : IDownloadService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
_client.Dispose();
|
_client.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
|
||||||
|
{
|
||||||
|
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
|
||||||
|
and not TorrentState.ForcedFetchingMetadata)
|
||||||
|
{
|
||||||
|
// ignore other states
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StrikeAndCheckLimit(torrent.Hash, torrent.Name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Common.Configuration;
|
using Common.Configuration.DownloadClient;
|
||||||
using Common.Configuration.DownloadClient;
|
using Common.Configuration.QueueCleaner;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Transmission.API.RPC;
|
using Transmission.API.RPC;
|
||||||
@@ -9,21 +10,20 @@ using Transmission.API.RPC.Entity;
|
|||||||
|
|
||||||
namespace Infrastructure.Verticals.DownloadClient.Transmission;
|
namespace Infrastructure.Verticals.DownloadClient.Transmission;
|
||||||
|
|
||||||
public sealed class TransmissionService : IDownloadService
|
public sealed class TransmissionService : DownloadServiceBase
|
||||||
{
|
{
|
||||||
private readonly ILogger<TransmissionService> _logger;
|
|
||||||
private readonly TransmissionConfig _config;
|
private readonly TransmissionConfig _config;
|
||||||
private readonly Client _client;
|
private readonly Client _client;
|
||||||
private readonly FilenameEvaluator _filenameEvaluator;
|
|
||||||
private TorrentInfo[]? _torrentsCache;
|
private TorrentInfo[]? _torrentsCache;
|
||||||
|
|
||||||
public TransmissionService(
|
public TransmissionService(
|
||||||
ILogger<TransmissionService> logger,
|
ILogger<TransmissionService> logger,
|
||||||
IOptions<TransmissionConfig> config,
|
IOptions<TransmissionConfig> config,
|
||||||
FilenameEvaluator filenameEvaluator
|
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||||
)
|
FilenameEvaluator filenameEvaluator,
|
||||||
|
Striker striker
|
||||||
|
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
|
||||||
_config = config.Value;
|
_config = config.Value;
|
||||||
_config.Validate();
|
_config.Validate();
|
||||||
_client = new(
|
_client = new(
|
||||||
@@ -31,44 +31,45 @@ public sealed class TransmissionService : IDownloadService
|
|||||||
login: _config.Username,
|
login: _config.Username,
|
||||||
password: _config.Password
|
password: _config.Password
|
||||||
);
|
);
|
||||||
_filenameEvaluator = filenameEvaluator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoginAsync()
|
public override async Task LoginAsync()
|
||||||
{
|
{
|
||||||
await _client.GetSessionInformationAsync();
|
await _client.GetSessionInformationAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||||
{
|
{
|
||||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||||
|
|
||||||
// if no files found, torrent might be stuck in Downloading metadata
|
if (torrent is null)
|
||||||
if (torrent?.FileStats?.Length is null or 0)
|
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool shouldRemove = torrent.FileStats?.Length > 0;
|
||||||
|
|
||||||
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? [])
|
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? [])
|
||||||
{
|
{
|
||||||
if (!stats.Wanted.HasValue)
|
if (!stats.Wanted.HasValue)
|
||||||
{
|
{
|
||||||
// if any files stats are missing, do not remove
|
// if any files stats are missing, do not remove
|
||||||
return false;
|
shouldRemove = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.Wanted.HasValue && stats.Wanted.Value)
|
if (stats.Wanted.HasValue && stats.Wanted.Value)
|
||||||
{
|
{
|
||||||
// if any files are wanted, do not remove
|
// if any files are wanted, do not remove
|
||||||
return false;
|
shouldRemove = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove if all files are unwanted
|
// remove if all files are unwanted
|
||||||
return true;
|
return shouldRemove || IsItemStuckAndShouldRemove(torrent);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task BlockUnwantedFilesAsync(string hash)
|
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||||
{
|
{
|
||||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||||
|
|
||||||
@@ -109,10 +110,26 @@ public sealed class TransmissionService : IDownloadService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
|
||||||
|
{
|
||||||
|
if (torrent.Status is not 4)
|
||||||
|
{
|
||||||
|
// not in downloading state
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (torrent.Eta > 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StrikeAndCheckLimit(torrent.HashString!, torrent.Name!);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TorrentInfo?> GetTorrentAsync(string hash)
|
private async Task<TorrentInfo?> GetTorrentAsync(string hash)
|
||||||
{
|
{
|
||||||
TorrentInfo? torrent = _torrentsCache?
|
TorrentInfo? torrent = _torrentsCache?
|
||||||
@@ -120,7 +137,15 @@ public sealed class TransmissionService : IDownloadService
|
|||||||
|
|
||||||
if (_torrentsCache is null || torrent is null)
|
if (_torrentsCache is null || torrent is null)
|
||||||
{
|
{
|
||||||
string[] fields = [TorrentFields.FILES, TorrentFields.FILE_STATS, TorrentFields.HASH_STRING, TorrentFields.ID];
|
string[] fields = [
|
||||||
|
TorrentFields.FILES,
|
||||||
|
TorrentFields.FILE_STATS,
|
||||||
|
TorrentFields.HASH_STRING,
|
||||||
|
TorrentFields.ID,
|
||||||
|
TorrentFields.ETA,
|
||||||
|
TorrentFields.NAME,
|
||||||
|
TorrentFields.STATUS
|
||||||
|
];
|
||||||
|
|
||||||
// refresh cache
|
// refresh cache
|
||||||
_torrentsCache = (await _client.TorrentGetAsync(fields))
|
_torrentsCache = (await _client.TorrentGetAsync(fields))
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Infrastructure.Verticals.ItemStriker;
|
||||||
|
|
||||||
|
public class Striker
|
||||||
|
{
|
||||||
|
private readonly ILogger<Striker> _logger;
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly MemoryCacheEntryOptions _cacheOptions;
|
||||||
|
|
||||||
|
public Striker(ILogger<Striker> logger, IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_cache = cache;
|
||||||
|
_cacheOptions = new MemoryCacheEntryOptions()
|
||||||
|
.SetSlidingExpiration(TimeSpan.FromHours(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
|
||||||
|
{
|
||||||
|
if (maxStrikes is 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = $"{strikeType.ToString()}_{hash}";
|
||||||
|
|
||||||
|
if (!_cache.TryGetValue(key, out int? strikeCount))
|
||||||
|
{
|
||||||
|
strikeCount = 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
++strikeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName);
|
||||||
|
_cache.Set(key, strikeCount, _cacheOptions);
|
||||||
|
|
||||||
|
if (strikeCount < maxStrikes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strikeCount > maxStrikes)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("blocked item keeps coming back | {name}", itemName);
|
||||||
|
_logger.LogWarning("be sure to enable \"Reject Blocklisted Torrent Hashes While Grabbing\" on your indexers to reject blocked items");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("removing item with max strikes | reason {reason} | {name}", strikeType.ToString(), itemName);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
using Common.Configuration.Arr;
|
using Common.Configuration.Arr;
|
||||||
using Domain.Arr.Queue;
|
|
||||||
using Domain.Enums;
|
using Domain.Enums;
|
||||||
using Domain.Models.Arr;
|
using Domain.Models.Arr;
|
||||||
|
using Domain.Models.Arr.Queue;
|
||||||
using Infrastructure.Verticals.Arr;
|
using Infrastructure.Verticals.Arr;
|
||||||
using Infrastructure.Verticals.DownloadClient;
|
using Infrastructure.Verticals.DownloadClient;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Infrastructure.Verticals.Jobs;
|
namespace Infrastructure.Verticals.Jobs;
|
||||||
|
|
||||||
@@ -88,18 +89,27 @@ public abstract class GenericHandler : IDisposable
|
|||||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||||
};
|
};
|
||||||
|
|
||||||
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record) =>
|
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record, bool isPack = false)
|
||||||
type switch
|
|
||||||
{
|
{
|
||||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode => new SonarrSearchItem
|
return type switch
|
||||||
|
{
|
||||||
|
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && !isPack => new SonarrSearchItem
|
||||||
{
|
{
|
||||||
Id = record.EpisodeId,
|
Id = record.EpisodeId,
|
||||||
SeriesId = record.SeriesId
|
SeriesId = record.SeriesId,
|
||||||
|
SearchType = SonarrSearchType.Episode
|
||||||
|
},
|
||||||
|
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && isPack => new SonarrSearchItem
|
||||||
|
{
|
||||||
|
Id = record.SeasonNumber,
|
||||||
|
SeriesId = record.SeriesId,
|
||||||
|
SearchType = SonarrSearchType.Season
|
||||||
},
|
},
|
||||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Season => new SonarrSearchItem
|
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Season => new SonarrSearchItem
|
||||||
{
|
{
|
||||||
Id = record.SeasonNumber,
|
Id = record.SeasonNumber,
|
||||||
SeriesId = record.SeriesId
|
SeriesId = record.SeriesId,
|
||||||
|
SearchType = SonarrSearchType.Series
|
||||||
},
|
},
|
||||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Series => new SonarrSearchItem
|
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Series => new SonarrSearchItem
|
||||||
{
|
{
|
||||||
@@ -111,4 +121,5 @@ public abstract class GenericHandler : IDisposable
|
|||||||
},
|
},
|
||||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using Common.Configuration;
|
|
||||||
using Common.Configuration.Arr;
|
using Common.Configuration.Arr;
|
||||||
using Domain.Arr.Queue;
|
using Common.Configuration.QueueCleaner;
|
||||||
using Domain.Enums;
|
using Domain.Enums;
|
||||||
using Domain.Models.Arr;
|
using Domain.Models.Arr;
|
||||||
|
using Domain.Models.Arr.Queue;
|
||||||
using Infrastructure.Verticals.Arr;
|
using Infrastructure.Verticals.Arr;
|
||||||
using Infrastructure.Verticals.DownloadClient;
|
using Infrastructure.Verticals.DownloadClient;
|
||||||
using Infrastructure.Verticals.Jobs;
|
using Infrastructure.Verticals.Jobs;
|
||||||
@@ -29,34 +29,45 @@ public sealed class QueueCleaner : GenericHandler
|
|||||||
{
|
{
|
||||||
HashSet<SearchItem> itemsToBeRefreshed = [];
|
HashSet<SearchItem> itemsToBeRefreshed = [];
|
||||||
ArrClient arrClient = GetClient(instanceType);
|
ArrClient arrClient = GetClient(instanceType);
|
||||||
|
ArrConfig arrConfig = GetConfig(instanceType);
|
||||||
|
|
||||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||||
{
|
{
|
||||||
foreach (QueueRecord record in items)
|
var groups = items
|
||||||
|
.GroupBy(x => x.DownloadId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
{
|
{
|
||||||
|
if (group.Any(x => !arrClient.IsRecordValid(x)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueRecord record = group.First();
|
||||||
|
|
||||||
if (record.Protocol is not "torrent")
|
if (record.Protocol is not "torrent")
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(record.DownloadId))
|
if (!arrClient.IsRecordValid(record))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId))
|
if (!arrClient.ShouldRemoveFromQueue(record) && !await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("skip | {title}", record.Title);
|
_logger.LogInformation("skip | {title}", record.Title);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record));
|
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record, group.Count() > 1));
|
||||||
|
|
||||||
await arrClient.DeleteQueueItemAsync(instance, record);
|
await arrClient.DeleteQueueItemAsync(instance, record);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await arrClient.RefreshItemsAsync(instance, GetConfig(instanceType), itemsToBeRefreshed);
|
await arrClient.RefreshItemsAsync(instance, arrConfig, itemsToBeRefreshed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,17 @@
|
|||||||
<pubDate>Sat, 24 Sep 2022 22:02:13 -0300</pubDate>
|
<pubDate>Sat, 24 Sep 2022 22:02:13 -0300</pubDate>
|
||||||
</item>
|
</item>
|
||||||
|
|
||||||
|
<item>
|
||||||
|
<title>Top.Gear.S23E03.720p.x265.HDTV.HEVC.-.YSTEAM</title>
|
||||||
|
<description>Test</description>
|
||||||
|
<size>4138858110</size>
|
||||||
|
<link>magnet:?xt=urn:btih:cf92cf859b110af0ad3d94b846e006828417b193&dn=TPG.2303.720p.x265.yourserie.com.mkv</link>
|
||||||
|
<guid isPermaLink="false">
|
||||||
|
174674a88c8947f6f5057ac3f81efde384ed216c2de43564ec450f2cb4677554
|
||||||
|
</guid>
|
||||||
|
<pubDate>Sat, 24 Sep 2022 22:02:13 -0300</pubDate>
|
||||||
|
</item>
|
||||||
|
|
||||||
<item>
|
<item>
|
||||||
<title>Top.Gear.S23E01.720p.x265.HDTV.HEVC.-.YSTEAM</title>
|
<title>Top.Gear.S23E01.720p.x265.HDTV.HEVC.-.YSTEAM</title>
|
||||||
<description>Test</description>
|
<description>Test</description>
|
||||||
@@ -65,5 +76,16 @@
|
|||||||
</guid>
|
</guid>
|
||||||
<pubDate>Sat, 24 Sep 2022 22:02:13 -0300</pubDate>
|
<pubDate>Sat, 24 Sep 2022 22:02:13 -0300</pubDate>
|
||||||
</item>
|
</item>
|
||||||
|
|
||||||
|
<item>
|
||||||
|
<title>Sherlock.S01.1080p.BluRay.DD5.1.x264-DON</title>
|
||||||
|
<description>Test</description>
|
||||||
|
<size>4138858110</size>
|
||||||
|
<link>http://nginx/custom/sonarr_bad_pack.torrent</link>
|
||||||
|
<guid isPermaLink="false">
|
||||||
|
174674a88c8947f6f9057ac3f82efde384ed216cade43564ec45gf2cb4677554
|
||||||
|
</guid>
|
||||||
|
<pubDate>Sat, 24 Sep 2022 22:02:13 -0300</pubDate>
|
||||||
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1734129464e4:infod5:filesld6:lengthi7e4:pathl47:Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkveee4:name40:Sherlock.S01.1080p.BluRay.DD5.1.x264-DON12:piece lengthi262144e6:pieces20:/˜ŽrÎèçƒlY€„·°|¶7ee
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
d10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1734129464e4:infod5:filesld6:lengthi7e4:pathl47:Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkveee4:name40:Sherlock.S01.1080p.BluRay.DD5.1.x264-DON12:piece lengthi262144e6:pieces20:/˜ŽrÎèçƒlY€„·°|¶7ee
|
||||||
@@ -1,5 +1 @@
|
|||||||
2b2ec156461d77bc48b8fe4d62cede50dcdff8e0
|
|
||||||
a4a1d1dd1db25763caa8f5e4d25ad72ef304094b
|
|
||||||
b72541215214be2a1d96ef6b29ca1305f5e5e1f6
|
|
||||||
59ab2bc053430fe53e06a93e2eadb7acb6a6bf2c
|
|
||||||
11cece7f8721c484126b66f609d52738ff1bbf1e
|
11cece7f8721c484126b66f609d52738ff1bbf1e
|
||||||
|
|||||||
Binary file not shown.
@@ -1,2 +1,2 @@
|
|||||||
[Stats]
|
[Stats]
|
||||||
AllStats=@Variant(\0\0\0\x1c\0\0\0\x2\0\0\0\x12\0\x41\0l\0l\0t\0i\0m\0\x65\0\x44\0L\0\0\0\x4\0\0\0\0\0\x61\xc0\xdf\0\0\0\x12\0\x41\0l\0l\0t\0i\0m\0\x65\0U\0L\0\0\0\x4\0\0\0\0\0\x9b\xf9\x8a)
|
AllStats=@Variant(\0\0\0\x1c\0\0\0\x2\0\0\0\x12\0\x41\0l\0l\0t\0i\0m\0\x65\0U\0L\0\0\0\x4\0\0\0\0\0\x9dm\x4\0\0\0\x12\0\x41\0l\0l\0t\0i\0m\0\x65\0\x44\0L\0\0\0\x4\0\0\0\0\0\x62_.)
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
episode
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
episode
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
episode
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1734129464e4:infod5:filesld6:lengthi7e4:pathl47:Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkveee4:name40:Sherlock.S01.1080p.BluRay.DD5.1.x264-DON12:piece lengthi262144e6:pieces20:/˜ŽrÎèçƒlY€„·°|¶7ee
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"update":{"sid":"4ee000d424144e078e7f3ef208e30647","did":"1df9f2cc-17dc-4130-9753-9b694f82f1b5","init":true,"started":"2024-12-13T22:41:57.8197572+00:00","timestamp":"2024-12-13T22:41:57.8202577+00:00","seq":0,"duration":0,"errors":0,"attrs":{"release":"4.0.10.2544-main","environment":"main"}}}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
146
|
144
|
||||||
@@ -172,7 +172,7 @@ services:
|
|||||||
container_name: cleanuperr
|
container_name: cleanuperr
|
||||||
environment:
|
environment:
|
||||||
- LOGGING__LOGLEVEL=Debug
|
- LOGGING__LOGLEVEL=Debug
|
||||||
- LOGGING__FILE__ENABLED=false
|
- LOGGING__FILE__ENABLED=true
|
||||||
- LOGGING__FILE__PATH=/var/logs
|
- LOGGING__FILE__PATH=/var/logs
|
||||||
- LOGGING__ENHANCED=true
|
- LOGGING__ENHANCED=true
|
||||||
|
|
||||||
@@ -181,6 +181,8 @@ services:
|
|||||||
|
|
||||||
- QUEUECLEANER__ENABLED=true
|
- QUEUECLEANER__ENABLED=true
|
||||||
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
||||||
|
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
|
||||||
|
- QUEUECLEANER__STALLED_MAX_STRIKES=5
|
||||||
|
|
||||||
- CONTENTBLOCKER__ENABLED=true
|
- CONTENTBLOCKER__ENABLED=true
|
||||||
- CONTENTBLOCKER__BLACKLIST__ENABLED=true
|
- CONTENTBLOCKER__BLACKLIST__ENABLED=true
|
||||||
|
|||||||
Reference in New Issue
Block a user