Add failed import safeguard for private torrents when download client is unavailable (#347)

This commit is contained in:
Flaminel
2025-10-23 18:27:28 +03:00
committed by GitHub
parent 905384034d
commit 89ef03a859
19 changed files with 1119 additions and 37 deletions

View File

@@ -25,6 +25,7 @@ public partial class DelugeService
return result;
}
result.IsPrivate = download.Private;
result.Found = true;
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
@@ -32,8 +33,6 @@ public partial class DelugeService
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
result.IsPrivate = download.Private;
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();

View File

@@ -28,8 +28,8 @@ public partial class DelugeService
return result;
}
result.Found = true;
result.IsPrivate = download.Private;
result.Found = true;
// Create ITorrentItem wrapper for consistent interface usage
var torrentItem = new DelugeItem(download);

View File

@@ -23,8 +23,6 @@ public partial class QBitService
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
result.Found = true;
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(hash);
@@ -48,6 +46,7 @@ public partial class QBitService
&& boolValue;
result.IsPrivate = isPrivate;
result.Found = true;
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();

View File

@@ -23,8 +23,6 @@ public partial class QBitService
return result;
}
result.Found = true;
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(hash);
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
@@ -38,6 +36,8 @@ public partial class QBitService
result.IsPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
bool.TryParse(dictValue?.ToString(), out bool boolValue)
&& boolValue;
result.Found = true;
// Create ITorrentItem wrapper for consistent interface usage
var torrentItem = new QBitItem(download, trackers, result.IsPrivate);

View File

@@ -23,8 +23,6 @@ public partial class TransmissionService
return result;
}
result.Found = true;
if (download.Files is null)
{
_logger.LogDebug("torrent {hash} has no files", hash);
@@ -39,6 +37,7 @@ public partial class TransmissionService
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
result.Found = true;
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();

View File

@@ -25,10 +25,9 @@ public partial class TransmissionService
return result;
}
result.Found = true;
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
result.Found = true;
// Create ITorrentItem wrapper for consistent interface usage
var torrentItem = new TransmissionItem(download);

View File

@@ -26,10 +26,9 @@ public partial class UTorrentService
return result;
}
result.Found = true;
var properties = await _client.GetTorrentPropertiesAsync(hash);
result.IsPrivate = properties.IsPrivate;
result.Found = true;
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))

View File

@@ -25,10 +25,9 @@ public partial class UTorrentService
return result;
}
result.Found = true;
var properties = await _client.GetTorrentPropertiesAsync(hash);
result.IsPrivate = properties.IsPrivate;
result.Found = true;
// Create ITorrentItem wrapper for consistent interface usage
var torrentItem = new UTorrentItemWrapper(download, properties);

View File

@@ -237,7 +237,7 @@ public abstract class GenericHandler : IHandler
}
}
if (downloadServices.Count == 0)
if (downloadServices.Count is 0)
{
_logger.LogDebug("No valid download clients found");
}
@@ -246,11 +246,6 @@ public abstract class GenericHandler : IHandler
_logger.LogDebug("Initialized {count} download clients", downloadServices.Count);
}
foreach (var downloadService in downloadServices)
{
await downloadService.LoginAsync();
}
return downloadServices;
}
}

View File

@@ -146,8 +146,9 @@ public sealed class MalwareBlocker : GenericHandler
ContextProvider.Set(nameof(QueueRecord), record);
BlockFilesResult result = new();
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
if (record.Protocol is "torrent")
if (isTorrent)
{
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)

View File

@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
@@ -94,6 +95,10 @@ public sealed class QueueCleaner : GenericHandler
ContextProvider.Set(nameof(InstanceType), instanceType);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
bool hasEnabledTorrentClients = ContextProvider
.Get<List<DownloadClientConfig>>(nameof(DownloadClientConfig))
.Where(x => x.Type == DownloadClientType.Torrent)
.Any(x => x.Enabled);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
@@ -135,8 +140,9 @@ public sealed class QueueCleaner : GenericHandler
ContextProvider.Set(nameof(QueueRecord), record);
DownloadCheckResult downloadCheckResult = new();
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
if (record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase))
if (isTorrent)
{
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
@@ -170,16 +176,12 @@ public sealed class QueueCleaner : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
if (downloadCheckResult.ShouldRemove)
{
bool removeFromClient = !downloadCheckResult.IsPrivate || downloadCheckResult.DeleteFromClient;
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instanceType,
@@ -189,11 +191,18 @@ public sealed class QueueCleaner : GenericHandler
removeFromClient,
downloadCheckResult.DeleteReason
);
continue;
}
// failed import check
// Skip failed import check if torrent is not found in client and skipIfNotFoundInClient is enabled
if (isTorrent && hasEnabledTorrentClients && !downloadCheckResult.Found && queueCleanerConfig.FailedImport.SkipIfNotFoundInClient)
{
_logger.LogInformation("skip | torrent not found in any torrent client | {title}", record.Title);
continue;
}
// Failed import check
bool shouldRemoveFromArr = await arrClient
.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddFailedImportPrivateSafeguardOption : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "failed_import_skip_if_not_found_in_client",
table: "queue_cleaner_configs",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "failed_import_skip_if_not_found_in_client",
table: "queue_cleaner_configs");
}
}
}

View File

@@ -717,6 +717,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_patterns");
b1.Property<bool>("SkipIfNotFoundInClient")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_skip_if_not_found_in_client");
});
b.HasKey("Id")

View File

@@ -9,11 +9,13 @@ namespace Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
public sealed record FailedImportConfig
{
public ushort MaxStrikes { get; init; }
public bool IgnorePrivate { get; init; }
public bool DeletePrivate { get; init; }
public bool SkipIfNotFoundInClient { get; init; } = true;
public IReadOnlyList<string> Patterns { get; init; } = [];
public PatternMode PatternMode { get; init; } = PatternMode.Include;

View File

@@ -198,8 +198,8 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.deletePrivate')"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.deletePrivate')"
title="Click for documentation"></i>
Delete Private from client
</label>
@@ -209,6 +209,19 @@
</div>
</div>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.skipIfNotFoundInClient')"
title="Click for documentation"></i>
Skip If Not Found In Client
</label>
<div class="field-input">
<p-checkbox formControlName="skipIfNotFoundInClient" [binary]="true"></p-checkbox>
<small class="form-helper-text">Skip failed import check for torrents not found in any enabled torrent client</small>
</div>
</div>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"

View File

@@ -557,6 +557,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
ignorePrivate: [{ value: false, disabled: true }],
deletePrivate: [{ value: false, disabled: true }],
skipIfNotFoundInClient: [{ value: true, disabled: true }],
patterns: [{ value: [], disabled: true }],
patternMode: [{ value: PatternMode.Include, disabled: true }],
}),
@@ -1020,13 +1021,14 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
if (enable) {
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.enable(options);
this.queueCleanerForm.get("failedImport")?.get("skipIfNotFoundInClient")?.enable(options);
this.queueCleanerForm.get("failedImport")?.get("patterns")?.enable(options);
this.queueCleanerForm.get("failedImport")?.get("patternMode")?.enable(options);
// Only enable deletePrivate if ignorePrivate is false
const ignorePrivate = this.queueCleanerForm.get("failedImport.ignorePrivate")?.value || false;
const deletePrivateControl = this.queueCleanerForm.get("failedImport.deletePrivate");
if (!ignorePrivate && deletePrivateControl) {
deletePrivateControl.enable(options);
} else if (deletePrivateControl) {
@@ -1035,6 +1037,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
} else {
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.disable(options);
this.queueCleanerForm.get("failedImport")?.get("deletePrivate")?.disable(options);
this.queueCleanerForm.get("failedImport")?.get("skipIfNotFoundInClient")?.disable(options);
this.queueCleanerForm.get("failedImport")?.get("patterns")?.disable(options);
this.queueCleanerForm.get("failedImport")?.get("patternMode")?.disable(options);
}
@@ -1068,6 +1071,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
maxStrikes: formValue.failedImport?.maxStrikes || 0,
ignorePrivate: formValue.failedImport?.ignorePrivate || false,
deletePrivate: formValue.failedImport?.deletePrivate || false,
skipIfNotFoundInClient: formValue.failedImport?.skipIfNotFoundInClient ?? true,
patterns: formValue.failedImport?.patterns || [],
patternMode: formValue.failedImport?.patternMode || PatternMode.Include,
},
@@ -1134,6 +1138,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
maxStrikes: 0,
ignorePrivate: false,
deletePrivate: false,
skipIfNotFoundInClient: true,
patterns: [],
patternMode: PatternMode.Include,
},

View File

@@ -30,6 +30,7 @@ export interface FailedImportConfig {
maxStrikes: number;
ignorePrivate: boolean;
deletePrivate: boolean;
skipIfNotFoundInClient: boolean;
patterns: string[];
patternMode?: PatternMode;
}