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;
}

View File

@@ -127,6 +127,19 @@ This setting needs a download client to be configured.
</ConfigSection>
<ConfigSection
title="Skip If Not Found In Client"
icon="🔍"
>
When enabled, torrents that are not found in any enabled torrent client will skip the failed import check. This is useful when the connection between Cleanuparr and your download client is temporarily broken or when the download client is down, preventing accidental removal of private torrents when their privacy status cannot be determined.
<Important>
This setting needs a download client to be configured.
</Important>
</ConfigSection>
<ConfigSection
title="Pattern Mode"
icon="🎭"