Compare commits

...

4 Commits

Author SHA1 Message Date
Flaminel
33a5bf9ab3 Add uTorrent support (#240) 2025-07-28 23:09:19 +03:00
Flaminel
de06d1c2d3 Fix download client type being sent as number instead of string (#245) 2025-07-27 14:23:48 +03:00
Flaminel
72855bc030 small fix on how_it_works page of the docs 2025-07-24 18:41:05 +03:00
eatsleepcoderepeat-gl
b185ea6899 Added new whitelist which includes subtitles (#243) 2025-07-24 12:50:03 +03:00
66 changed files with 2920 additions and 787 deletions

View File

@@ -25,21 +25,24 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
## 🎯 Supported Applications
### *Arr Applications
- **Sonarr** (TV Shows)
- **Radarr** (Movies)
- **Lidarr** (Music)
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr**
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
- **µTorrent**
### Platforms
- **Docker** (Linux, Windows, macOS)
- **Windows** (Native installer)
- **macOS** (Intel & Apple Silicon)
- **Linux** (Portable executable)
- **Unraid** (Community Apps)
- **Docker**
- **Windows**
- **macOS**
- **Linux**
- **Unraid**
## 🚀 Quick Start
@@ -55,7 +58,7 @@ docker run -d --name cleanuparr \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see our [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
### 🌐 Access the Web Interface

View File

@@ -0,0 +1,69 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Request;
/// <summary>
/// Represents a request to the µTorrent Web UI API
/// </summary>
public sealed class UTorrentRequest
{
/// <summary>
/// The API action to perform
/// </summary>
public string Action { get; set; } = string.Empty;
/// <summary>
/// Authentication token (required for CSRF protection)
/// </summary>
public string Token { get; set; } = string.Empty;
/// <summary>
/// Additional parameters for the request
/// </summary>
public List<(string Name, string Value)> Parameters { get; set; } = new();
/// <summary>
/// Constructs the query string for the API call
/// </summary>
/// <returns>The complete query string including token and action</returns>
public string ToQueryString()
{
var queryParams = new List<string>
{
$"token={Token}",
Action
};
foreach (var param in Parameters)
{
queryParams.Add($"{Uri.EscapeDataString(param.Name)}={Uri.EscapeDataString(param.Value)}");
}
return string.Join("&", queryParams);
}
/// <summary>
/// Creates a new request with the specified action
/// </summary>
/// <param name="action">The API action</param>
/// <param name="token">Authentication token</param>
/// <returns>A new UTorrentRequest instance</returns>
public static UTorrentRequest Create(string action, string token)
{
return new UTorrentRequest
{
Action = action,
Token = token
};
}
/// <summary>
/// Adds a parameter to the request
/// </summary>
/// <param name="key">Parameter name</param>
/// <param name="value">Parameter value</param>
/// <returns>This instance for method chaining</returns>
public UTorrentRequest WithParameter(string key, string value)
{
Parameters.Add((key, value));
return this;
}
}

View File

@@ -0,0 +1,28 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for file list API calls
/// Replaces the generic UTorrentResponse<T> for file listings
/// </summary>
public sealed class FileListResponse
{
/// <summary>
/// Raw file data from the API
/// </summary>
[JsonProperty(PropertyName = "files")]
public object[]? FilesRaw { get; set; }
/// <summary>
/// Torrent hash for which files are listed
/// </summary>
[JsonIgnore]
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Parsed files as strongly-typed objects
/// </summary>
[JsonIgnore]
public List<UTorrentFile> Files { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for label list API calls
/// Replaces the generic UTorrentResponse<T> for label listings
/// </summary>
public sealed class LabelListResponse
{
/// <summary>
/// Raw label data from the API
/// </summary>
[JsonProperty(PropertyName = "label")]
public object[][]? LabelsRaw { get; set; }
/// <summary>
/// Parsed labels as string list
/// </summary>
[JsonIgnore]
public List<string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for torrent properties API calls
/// Replaces the generic UTorrentResponse<T> for properties retrieval
/// </summary>
public sealed class PropertiesResponse
{
/// <summary>
/// Raw properties data from the API
/// </summary>
[JsonProperty(PropertyName = "props")]
public object[]? PropertiesRaw { get; set; }
/// <summary>
/// Parsed properties as strongly-typed object
/// </summary>
[JsonIgnore]
public UTorrentProperties Properties { get; set; } = new();
}

View File

@@ -0,0 +1,40 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for torrent list API calls
/// Replaces the generic UTorrentResponse<T> for torrent listings
/// </summary>
public sealed class TorrentListResponse
{
/// <summary>
/// µTorrent build number
/// </summary>
[JsonProperty(PropertyName = "build")]
public int Build { get; set; }
/// <summary>
/// List of torrent data from the API
/// </summary>
[JsonProperty(PropertyName = "torrents")]
public object[][]? TorrentsRaw { get; set; }
/// <summary>
/// Label data from the API
/// </summary>
[JsonProperty(PropertyName = "label")]
public object[][]? LabelsRaw { get; set; }
/// <summary>
/// Parsed torrents as strongly-typed objects
/// </summary>
[JsonIgnore]
public List<UTorrentItem> Torrents { get; set; } = new();
/// <summary>
/// Parsed labels as string list
/// </summary>
[JsonIgnore]
public List<string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,18 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents a file within a torrent from µTorrent Web UI API
/// Based on the files array structure from the API documentation
/// </summary>
public sealed class UTorrentFile
{
public string Name { get; set; } = string.Empty;
public long Size { get; set; }
public long Downloaded { get; set; }
public int Priority { get; set; }
public int Index { get; set; }
}

View File

@@ -0,0 +1,181 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents a torrent from µTorrent Web UI API
/// Based on the torrent array structure from the API documentation
/// </summary>
public sealed class UTorrentItem
{
/// <summary>
/// Torrent hash (index 0)
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Status bitfield (index 1)
/// </summary>
public int Status { get; set; }
/// <summary>
/// Torrent name (index 2)
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Total size in bytes (index 3)
/// </summary>
public long Size { get; set; }
/// <summary>
/// Progress in permille (1000 = 100%) (index 4)
/// </summary>
public int Progress { get; set; }
/// <summary>
/// Downloaded bytes (index 5)
/// </summary>
public long Downloaded { get; set; }
/// <summary>
/// Uploaded bytes (index 6)
/// </summary>
public long Uploaded { get; set; }
/// <summary>
/// Ratio * 1000 (index 7)
/// </summary>
public int RatioRaw { get; set; }
/// <summary>
/// Upload speed in bytes/sec (index 8)
/// </summary>
public int UploadSpeed { get; set; }
/// <summary>
/// Download speed in bytes/sec (index 9)
/// </summary>
public int DownloadSpeed { get; set; }
/// <summary>
/// ETA in seconds (index 10)
/// </summary>
public int ETA { get; set; }
/// <summary>
/// Label (index 11)
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// Connected peers (index 12)
/// </summary>
public int PeersConnected { get; set; }
/// <summary>
/// Peers in swarm (index 13)
/// </summary>
public int PeersInSwarm { get; set; }
/// <summary>
/// Connected seeds (index 14)
/// </summary>
public int SeedsConnected { get; set; }
/// <summary>
/// Seeds in swarm (index 15)
/// </summary>
public int SeedsInSwarm { get; set; }
/// <summary>
/// Availability (index 16)
/// </summary>
public int Availability { get; set; }
/// <summary>
/// Queue order (index 17)
/// </summary>
public int QueueOrder { get; set; }
/// <summary>
/// Remaining bytes (index 18)
/// </summary>
public long Remaining { get; set; }
/// <summary>
/// Download URL (index 19)
/// </summary>
public string DownloadUrl { get; set; } = string.Empty;
/// <summary>
/// RSS feed URL (index 20)
/// </summary>
public string RssFeedUrl { get; set; } = string.Empty;
/// <summary>
/// Status message (index 21)
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Stream ID (index 22)
/// </summary>
public string StreamId { get; set; } = string.Empty;
/// <summary>
/// Date added as Unix timestamp (index 23)
/// </summary>
public long DateAdded { get; set; }
/// <summary>
/// Date completed as Unix timestamp (index 24)
/// </summary>
public long DateCompleted { get; set; }
/// <summary>
/// App update URL (index 25)
/// </summary>
public string AppUpdateUrl { get; set; } = string.Empty;
/// <summary>
/// Save path (index 26)
/// </summary>
public string SavePath { get; set; } = string.Empty;
/// <summary>
/// Calculated ratio value (RatioRaw / 1000.0)
/// </summary>
[JsonIgnore]
public double Ratio => RatioRaw / 1000.0;
/// <summary>
/// Progress as percentage (0.0 to 1.0)
/// </summary>
[JsonIgnore]
public double ProgressPercent => Progress / 1000.0;
/// <summary>
/// Date completed as DateTime (or null if not completed)
/// </summary>
[JsonIgnore]
public DateTime? DateCompletedDateTime =>
DateCompleted > 0 ? DateTimeOffset.FromUnixTimeSeconds(DateCompleted).DateTime : null;
/// <summary>
/// Seeding time in seconds (calculated from DateCompleted to now)
/// </summary>
[JsonIgnore]
public TimeSpan? SeedingTime
{
get
{
if (DateCompletedDateTime.HasValue)
{
return DateTime.UtcNow - DateCompletedDateTime.Value;
}
return null;
}
}
}

View File

@@ -0,0 +1,85 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents torrent properties from µTorrent Web UI API getprops action
/// Based on the properties structure from the API documentation
/// </summary>
public sealed class UTorrentProperties
{
/// <summary>
/// Torrent hash
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Trackers list (newlines are represented by \r\n)
/// </summary>
public string Trackers { get; set; } = string.Empty;
public List<string> TrackerList => Trackers
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
/// <summary>
/// Upload limit in bytes per second
/// </summary>
public int UploadLimit { get; set; }
/// <summary>
/// Download limit in bytes per second
/// </summary>
public int DownloadLimit { get; set; }
/// <summary>
/// Initial seeding / Super seeding
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int SuperSeed { get; set; }
/// <summary>
/// Use DHT
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int Dht { get; set; }
/// <summary>
/// Use PEX (Peer Exchange)
/// -1 = Not allowed (indicates private torrent), 0 = Disabled, 1 = Enabled
/// </summary>
public int Pex { get; set; }
/// <summary>
/// Override queueing
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int SeedOverride { get; set; }
/// <summary>
/// Seed ratio in per mils (1000 = 1.0 ratio)
/// </summary>
public int SeedRatio { get; set; }
/// <summary>
/// Seeding time in seconds
/// 0 = No minimum seeding time
/// </summary>
public int SeedTime { get; set; }
/// <summary>
/// Upload slots
/// </summary>
public int UploadSlots { get; set; }
/// <summary>
/// Whether this torrent is private (based on PEX value)
/// Private torrents have PEX = -1 (not allowed)
/// </summary>
public bool IsPrivate => Pex == -1;
/// <summary>
/// Calculated seed ratio value (SeedRatio / 1000.0)
/// </summary>
public double SeedRatioValue => SeedRatio / 1000.0;
}

View File

@@ -0,0 +1,61 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Base response wrapper for µTorrent Web UI API calls
/// </summary>
public sealed record UTorrentResponse<T>
{
[JsonProperty(PropertyName = "build")]
public int Build { get; set; }
[JsonProperty(PropertyName = "label")]
public object[][]? Labels { get; set; }
[JsonProperty(PropertyName = "torrents")]
public T? Torrents { get; set; }
[JsonProperty(PropertyName = "torrentp")]
public object[]? TorrentProperties { get; set; }
[JsonProperty(PropertyName = "files")]
public object[]? FilesDto { get; set; }
[JsonIgnore]
public List<UTorrentFile>? Files
{
get
{
if (FilesDto is null || FilesDto.Length < 2)
{
return null;
}
var files = new List<UTorrentFile>();
if (FilesDto[1] is JArray jArray)
{
foreach (var jToken in jArray)
{
var fileTokenArray = (JArray)jToken;
var fileArray = fileTokenArray.ToObject<object[]>() ?? [];
files.Add(new UTorrentFile
{
Name = fileArray[0].ToString() ?? string.Empty,
Size = Convert.ToInt64(fileArray[1]),
Downloaded = Convert.ToInt64(fileArray[2]),
Priority = Convert.ToInt32(fileArray[3]),
});
}
}
return files;
}
}
[JsonProperty(PropertyName = "props")]
public UTorrentProperties[]? Properties { get; set; }
}

View File

@@ -2,7 +2,8 @@
public enum DownloadClientTypeName
{
QBittorrent,
qBittorrent,
Deluge,
Transmission,
}
uTorrent,
}

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public class DelugeClientException : Exception
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public sealed class DelugeLoginException : DelugeClientException
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public sealed class DelugeLogoutException : DelugeClientException
{

View File

@@ -0,0 +1,15 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Exception thrown when µTorrent authentication fails
/// </summary>
public class UTorrentAuthenticationException : UTorrentException
{
public UTorrentAuthenticationException(string message) : base(message)
{
}
public UTorrentAuthenticationException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Exceptions;
public class UTorrentException : Exception
{
public UTorrentException(string message) : base(message)
{
}
public UTorrentException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,22 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Exception thrown when µTorrent response parsing fails
/// </summary>
public class UTorrentParsingException : UTorrentException
{
/// <summary>
/// The raw response that failed to parse
/// </summary>
public string RawResponse { get; }
public UTorrentParsingException(string message, string rawResponse) : base(message)
{
RawResponse = rawResponse;
}
public UTorrentParsingException(string message, string rawResponse, Exception innerException) : base(message, innerException)
{
RawResponse = rawResponse;
}
}

View File

@@ -16,6 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />

View File

@@ -0,0 +1,266 @@
using System.Net;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Newtonsoft.Json;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Verticals.DownloadClient;
public class UTorrentClientTests
{
private readonly UTorrentClient _client;
private readonly Mock<HttpMessageHandler> _mockHttpHandler;
private readonly DownloadClientConfig _config;
private readonly Mock<IUTorrentAuthenticator> _mockAuthenticator;
private readonly Mock<IUTorrentHttpService> _mockHttpService;
private readonly Mock<IUTorrentResponseParser> _mockResponseParser;
private readonly Mock<ILogger<UTorrentClient>> _mockLogger;
public UTorrentClientTests()
{
_mockHttpHandler = new Mock<HttpMessageHandler>();
_mockAuthenticator = new Mock<IUTorrentAuthenticator>();
_mockHttpService = new Mock<IUTorrentHttpService>();
_mockResponseParser = new Mock<IUTorrentResponseParser>();
_mockLogger = new Mock<ILogger<UTorrentClient>>();
_config = new DownloadClientConfig
{
Name = "test",
Type = DownloadClientType.Torrent,
TypeName = DownloadClientTypeName.uTorrent,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "password"
};
_client = new UTorrentClient(
_config,
_mockAuthenticator.Object,
_mockHttpService.Object,
_mockResponseParser.Object,
_mockLogger.Object
);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldDeserializeMixedArrayCorrectly()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = new object[]
{
"F0616FB199B78254474AF6D72705177E71D713ED", // Hash (string)
new object[] // File 1
{
"test name",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
},
new object[] // File 2
{
"Dir1/Dir11/test11.zipx",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
},
new object[] // File 3
{
"Dir1/sample.txt",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
}
}
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Equal(3, files.Count);
Assert.Equal("test name", files[0].Name);
Assert.Equal(2604L, files[0].Size);
Assert.Equal(0L, files[0].Downloaded);
Assert.Equal(2, files[0].Priority);
Assert.Equal(0, files[0].Index);
Assert.Equal("Dir1/Dir11/test11.zipx", files[1].Name);
Assert.Equal(2604L, files[1].Size);
Assert.Equal(0L, files[1].Downloaded);
Assert.Equal(2, files[1].Priority);
Assert.Equal(1, files[1].Index);
Assert.Equal("Dir1/sample.txt", files[2].Name);
Assert.Equal(2604L, files[2].Size);
Assert.Equal(0L, files[2].Downloaded);
Assert.Equal(2, files[2].Priority);
Assert.Equal(2, files[2].Index);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldHandleEmptyResponse()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = new object[]
{
"F0616FB199B78254474AF6D72705177E71D713ED" // Only hash, no files
}
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Empty(files);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldHandleNullResponse()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = null
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Empty(files);
}
}

View File

@@ -4,7 +4,6 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.Extensions;
using Cleanuparr.Persistence.Models.Configuration;
using Data.Models.Deluge.Exceptions;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;

View File

@@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging;
using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService;
using QBitService = Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent.QBitService;
using TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService;
using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
@@ -46,9 +47,10 @@ public sealed class DownloadServiceFactory
return downloadClientConfig.TypeName switch
{
DownloadClientTypeName.QBittorrent => CreateQBitService(downloadClientConfig),
DownloadClientTypeName.qBittorrent => CreateQBitService(downloadClientConfig),
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig),
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
};
}
@@ -115,4 +117,26 @@ public sealed class DownloadServiceFactory
return service;
}
private UTorrentService CreateUTorrentService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<UTorrentService>>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
// Create the UTorrentService instance
UTorrentService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory
);
return service;
}
}

View File

@@ -39,7 +39,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
_logger.LogError("Failed to find torrent properties {name}", download.Name);
return result;
}
@@ -60,9 +60,9 @@ public partial class QBitService
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
if (files is null)
if (files?.Count is null or 0)
{
_logger.LogDebug("torrent {hash} has no files", hash);
_logger.LogDebug("skip files check | no files found | {name}", download.Name);
return result;
}

View File

@@ -97,7 +97,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties | {name}", download.Name);
_logger.LogError("Failed to find torrent properties | {name}", download.Name);
return;
}

View File

@@ -38,7 +38,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash}", download.Name);
_logger.LogError("Failed to find torrent properties for {name}", download.Name);
return result;
}
@@ -89,32 +89,32 @@ public partial class QBitService
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogDebug("skip slow check | max strikes is 0 | {name}", download.Name);
_logger.LogTrace("skip slow check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload))
{
_logger.LogDebug("skip slow check | download is in {state} state | {name}", download.State, download.Name);
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.State, download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogDebug("skip slow check | download speed is 0 | {name}", download.Name);
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
{
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -139,7 +139,7 @@ public partial class QBitService
if (queueCleanerConfig.Stalled.MaxStrikes is 0 && queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes is 0)
{
_logger.LogDebug("skip stalled check | max strikes is 0 | {name}", torrent.Name);
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", torrent.Name);
return (false, DeleteReason.None);
}
@@ -147,7 +147,7 @@ public partial class QBitService
and not TorrentState.ForcedFetchingMetadata)
{
// ignore other states
_logger.LogDebug("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
return (false, DeleteReason.None);
}
@@ -156,7 +156,7 @@ public partial class QBitService
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
_logger.LogTrace("skip stalled check | download is private | {name}", torrent.Name);
}
else
{
@@ -175,7 +175,7 @@ public partial class QBitService
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
}
_logger.LogDebug("skip stalled check | download is not stalled | {name}", torrent.Name);
_logger.LogTrace("skip stalled check | download is not stalled | {name}", torrent.Name);
return (false, DeleteReason.None);
}
}

View File

@@ -0,0 +1,77 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
/// <summary>
/// Extension methods for µTorrent entities and status checking
/// </summary>
public static class UTorrentExtensions
{
/// <summary>
/// Checks if the torrent is currently seeding
/// </summary>
public static bool IsSeeding(this UTorrentItem item)
{
return IsDownloading(item.Status) && item.DateCompleted > 0;
}
/// <summary>
/// Checks if the torrent is currently downloading
/// </summary>
public static bool IsDownloading(this UTorrentItem item)
{
return IsDownloading(item.Status);
}
/// <summary>
/// Checks if the status indicates downloading
/// </summary>
public static bool IsDownloading(int status)
{
return (status & UTorrentStatus.Started) != 0 &&
(status & UTorrentStatus.Checked) != 0 &&
(status & UTorrentStatus.Error) == 0;
}
/// <summary>
/// Checks if a torrent should be ignored based on the ignored patterns
/// </summary>
public static bool ShouldIgnore(this UTorrentItem download, IReadOnlyList<string> ignoredDownloads)
{
foreach (string value in ignoredDownloads)
{
if (download.Hash.Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (download.Label.Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
}
public static bool ShouldIgnore(this string tracker, IReadOnlyList<string> ignoredDownloads)
{
string? trackerUrl = UriService.GetDomain(tracker);
if (trackerUrl is null)
{
return false;
}
foreach (string value in ignoredDownloads)
{
if (trackerUrl.EndsWith(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,46 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for µTorrent authentication management with caching support
/// Handles token management and session state with multi-client support
/// </summary>
public interface IUTorrentAuthenticator
{
/// <summary>
/// Ensures that the client is authenticated and the token is valid
/// </summary>
/// <returns>True if authentication is successful</returns>
Task<bool> EnsureAuthenticatedAsync();
/// <summary>
/// Gets a valid authentication token, refreshing if necessary
/// </summary>
/// <returns>Valid authentication token</returns>
Task<string> GetValidTokenAsync();
/// <summary>
/// Gets a valid GUID cookie, refreshing if necessary
/// </summary>
/// <returns>Valid GUID cookie</returns>
Task<string> GetValidGuidCookieAsync();
/// <summary>
/// Forces a refresh of the authentication session
/// </summary>
Task RefreshSessionAsync();
/// <summary>
/// Invalidates the cached authentication session
/// </summary>
Task InvalidateSessionAsync();
/// <summary>
/// Gets whether the client is currently authenticated
/// </summary>
bool IsAuthenticated { get; }
/// <summary>
/// Gets the GUID cookie for the current session
/// </summary>
string GuidCookie { get; }
}

View File

@@ -0,0 +1,24 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for raw HTTP communication with µTorrent Web UI API
/// Handles low-level HTTP requests and authentication token retrieval
/// </summary>
public interface IUTorrentHttpService
{
/// <summary>
/// Sends a raw HTTP request to the µTorrent API
/// </summary>
/// <param name="request">The request to send</param>
/// <param name="guidCookie">The GUID cookie for authentication</param>
/// <returns>Raw JSON response from the API</returns>
Task<string> SendRawRequestAsync(UTorrentRequest request, string guidCookie);
/// <summary>
/// Retrieves authentication token and GUID cookie from µTorrent
/// </summary>
/// <returns>Tuple containing the authentication token and GUID cookie</returns>
Task<(string token, string guidCookie)> GetTokenAndCookieAsync();
}

View File

@@ -0,0 +1,38 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for parsing µTorrent API responses
/// Provides endpoint-specific parsing methods for different response types
/// </summary>
public interface IUTorrentResponseParser
{
/// <summary>
/// Parses a torrent list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed torrent list response</returns>
TorrentListResponse ParseTorrentList(string json);
/// <summary>
/// Parses a file list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed file list response</returns>
FileListResponse ParseFileList(string json);
/// <summary>
/// Parses a properties response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed properties response</returns>
PropertiesResponse ParseProperties(string json);
/// <summary>
/// Parses a label list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed label list response</returns>
LabelListResponse ParseLabelList(string json);
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for µTorrent download service
/// </summary>
public interface IUTorrentService : IDownloadService
{
}

View File

@@ -0,0 +1,16 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Represents cached authentication data for a µTorrent client instance
/// </summary>
public sealed class UTorrentAuthCache
{
public string AuthToken { get; init; } = string.Empty;
public string GuidCookie { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
public DateTime ExpiresAt { get; init; }
public bool IsValid => DateTime.UtcNow < ExpiresAt &&
!string.IsNullOrEmpty(AuthToken) &&
!string.IsNullOrEmpty(GuidCookie);
}

View File

@@ -0,0 +1,237 @@
using System.Collections.Concurrent;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of µTorrent authentication management with IMemoryCache-based token sharing
/// Handles concurrent authentication requests and provides thread-safe token caching per client
/// </summary>
public class UTorrentAuthenticator : IUTorrentAuthenticator
{
private readonly IMemoryCache _cache;
private readonly IUTorrentHttpService _httpService;
private readonly DownloadClientConfig _config;
private readonly ILogger<UTorrentAuthenticator> _logger;
// Use a static concurrent dictionary to ensure same client instances share the same semaphore
// This prevents multiple instances of the same client from authenticating simultaneously
private readonly SemaphoreSlim _authSemaphore;
private readonly string _clientKey;
// Cache configuration - conservative timings to avoid token expiration issues
private static readonly TimeSpan TokenExpiryDuration = TimeSpan.FromMinutes(20);
private static readonly TimeSpan CacheAbsoluteExpiration = TimeSpan.FromMinutes(25);
public UTorrentAuthenticator(
IMemoryCache cache,
IUTorrentHttpService httpService,
DownloadClientConfig config,
ILogger<UTorrentAuthenticator> logger)
{
_cache = cache;
_httpService = httpService;
_config = config;
_logger = logger;
// Create unique client key based on connection details
// This ensures different µTorrent instances don't share auth tokens
_clientKey = GetClientKey();
// Get or create semaphore for this specific client configuration
if (_cache.TryGetValue<SemaphoreSlim>(_clientKey, out var authSemaphore) && authSemaphore is not null)
{
_authSemaphore = authSemaphore;
return;
}
_authSemaphore = new SemaphoreSlim(1, 1);
_cache.Set(_clientKey, _authSemaphore, Constants.DefaultCacheEntryOptions);
}
/// <inheritdoc/>
public bool IsAuthenticated
{
get
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
return _cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true;
}
}
/// <inheritdoc/>
public string GuidCookie
{
get
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.GuidCookie;
}
return string.Empty;
}
}
/// <inheritdoc/>
public async Task<bool> EnsureAuthenticatedAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
// Fast path: Check if we have valid cached auth
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return true;
}
// Slow path: Need to refresh authentication with concurrency control
return await RefreshAuthenticationWithLockAsync();
}
/// <inheritdoc/>
public async Task<string> GetValidTokenAsync()
{
if (!await EnsureAuthenticatedAsync())
{
throw new UTorrentAuthenticationException($"Failed to authenticate with µTorrent client '{_config.Name}'");
}
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.AuthToken;
}
throw new UTorrentAuthenticationException($"Authentication token not available for µTorrent client '{_config.Name}'");
}
/// <inheritdoc/>
public async Task<string> GetValidGuidCookieAsync()
{
if (!await EnsureAuthenticatedAsync())
{
throw new UTorrentAuthenticationException($"Failed to authenticate with µTorrent client '{_config.Name}'");
}
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.GuidCookie;
}
throw new UTorrentAuthenticationException($"GUID cookie not available for µTorrent client '{_config.Name}'");
}
/// <inheritdoc/>
public async Task RefreshSessionAsync()
{
const int maxRetries = 3;
var retryCount = 0;
var backoffDelay = TimeSpan.FromMilliseconds(500);
while (retryCount < maxRetries)
{
try
{
var (token, guidCookie) = await _httpService.GetTokenAndCookieAsync();
var authCache = new UTorrentAuthCache
{
AuthToken = token,
GuidCookie = guidCookie,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.Add(TokenExpiryDuration)
};
// Cache with both sliding and absolute expiration
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = CacheAbsoluteExpiration,
SlidingExpiration = TokenExpiryDuration,
Priority = CacheItemPriority.High
};
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
_cache.Set(cacheKey, authCache, cacheOptions);
return;
}
catch (Exception ex) when (retryCount < maxRetries - 1)
{
retryCount++;
_logger.LogWarning(ex, "Authentication attempt {Attempt} failed for µTorrent client '{ClientName}', retrying in {Delay}ms",
retryCount, _config.Name, backoffDelay.TotalMilliseconds);
await Task.Delay(backoffDelay);
backoffDelay = TimeSpan.FromMilliseconds(backoffDelay.TotalMilliseconds * 1.5); // Exponential backoff
}
catch (Exception ex)
{
// Invalidate any existing cache entry on failure
await InvalidateSessionAsync();
throw new UTorrentAuthenticationException($"Failed to refresh authentication session after {maxRetries} attempts: {ex.Message}", ex);
}
}
}
/// <inheritdoc/>
public async Task InvalidateSessionAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
_cache.Remove(cacheKey);
await Task.CompletedTask;
}
/// <summary>
/// Refreshes authentication with concurrency control to prevent multiple simultaneous auth requests
/// </summary>
private async Task<bool> RefreshAuthenticationWithLockAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
// Wait for our turn to authenticate (per client instance)
await _authSemaphore.WaitAsync();
try
{
// Double-check: another thread might have refreshed while we were waiting
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return true;
}
// Actually refresh the authentication
await RefreshSessionAsync();
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh authentication for µTorrent client '{ClientName}'", _config.Name);
return false;
}
finally
{
_authSemaphore.Release();
}
}
/// <summary>
/// Creates a unique client key based on connection details
/// This ensures different µTorrent instances don't share auth tokens
/// </summary>
private string GetClientKey()
{
return _config.Url.ToString();
}
}

View File

@@ -0,0 +1,280 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public sealed class UTorrentClient
{
private readonly DownloadClientConfig _config;
private readonly IUTorrentAuthenticator _authenticator;
private readonly IUTorrentHttpService _httpService;
private readonly IUTorrentResponseParser _responseParser;
private readonly ILogger<UTorrentClient> _logger;
public UTorrentClient(
DownloadClientConfig config,
IUTorrentAuthenticator authenticator,
IUTorrentHttpService httpService,
IUTorrentResponseParser responseParser,
ILogger<UTorrentClient> logger
)
{
_config = config;
_authenticator = authenticator;
_httpService = httpService;
_responseParser = responseParser;
_logger = logger;
}
/// <summary>
/// Authenticates with µTorrent and retrieves the authentication token
/// </summary>
/// <returns>True if authentication was successful</returns>
public async Task<bool> LoginAsync()
{
try
{
// Use the cache-aware authentication
var token = await _authenticator.GetValidTokenAsync();
return !string.IsNullOrEmpty(token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Login failed for µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to authenticate with µTorrent: {ex.Message}", ex);
}
}
/// <summary>
/// Tests the authentication and basic API connectivity
/// </summary>
/// <returns>True if authentication and basic API call works</returns>
public async Task<bool> TestConnectionAsync()
{
try
{
var torrents = await GetTorrentsAsync();
return true; // If we can get torrents, authentication is working
}
catch
{
return false;
}
}
/// <summary>
/// Gets all torrents from µTorrent
/// </summary>
/// <returns>List of torrents</returns>
public async Task<List<UTorrentItem>> GetTorrentsAsync()
{
try
{
var request = UTorrentRequestFactory.CreateTorrentListRequest();
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseTorrentList(json);
return response.Torrents;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get torrents from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to get torrents: {ex.Message}", ex);
}
}
/// <summary>
/// Gets a specific torrent by hash
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>The torrent or null if not found</returns>
public async Task<UTorrentItem?> GetTorrentAsync(string hash)
{
try
{
var torrents = await GetTorrentsAsync();
return torrents.FirstOrDefault(t =>
string.Equals(t.Hash, hash, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets files for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>List of files in the torrent</returns>
public async Task<List<UTorrentFile>?> GetTorrentFilesAsync(string hash)
{
try
{
var request = UTorrentRequestFactory.CreateFileListRequest(hash);
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseFileList(json);
return response.Files;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get files for torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get files for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets torrent properties including private/public status
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>UTorrentProperties object or null if not found</returns>
public async Task<UTorrentProperties> GetTorrentPropertiesAsync(string hash)
{
try
{
var request = UTorrentRequestFactory.CreatePropertiesRequest(hash);
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseProperties(json);
return response.Properties;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get properties for torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get properties for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets all labels from µTorrent
/// </summary>
/// <returns>List of label names</returns>
public async Task<List<string>> GetLabelsAsync()
{
try
{
var request = UTorrentRequestFactory.CreateLabelListRequest();
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseLabelList(json);
return response.Labels;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get labels from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to get labels: {ex.Message}", ex);
}
}
/// <summary>
/// Sets the label for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="label">Label to set</param>
public async Task SetTorrentLabelAsync(string hash, string label)
{
try
{
var request = UTorrentRequestFactory.CreateSetLabelRequest(hash, label);
await SendAuthenticatedRequestAsync(request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to set label '{Label}' for torrent {Hash} in µTorrent client '{ClientName}'", label, hash, _config.Name);
throw new UTorrentException($"Failed to set label '{label}' for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Sets file priorities for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="fileIndexes">Index of the file to set priority for</param>
/// <param name="priority">File priority (0=skip, 1=low, 2=normal, 3=high)</param>
public async Task SetFilesPriorityAsync(string hash, List<int> fileIndexes, int priority)
{
try
{
var request = UTorrentRequestFactory.CreateSetFilePrioritiesRequest(hash, fileIndexes, priority);
await SendAuthenticatedRequestAsync(request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to set file priority for torrent {Hash} in µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to set file priority for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Removes torrents from µTorrent
/// </summary>
/// <param name="hashes">List of torrent hashes to remove</param>
public async Task RemoveTorrentsAsync(List<string> hashes)
{
try
{
foreach (var hash in hashes)
{
var request = UTorrentRequestFactory.CreateRemoveTorrentWithDataRequest(hash);
await SendAuthenticatedRequestAsync(request);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove torrents from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to remove torrents: {ex.Message}", ex);
}
}
/// <summary>
/// Creates a new label in µTorrent
/// </summary>
/// <param name="label">Label name to create</param>
public static async Task CreateLabel(string label)
{
// µTorrent doesn't have an explicit "create label" API
// Labels are created automatically when you assign them to a torrent
// So this is a no-op for µTorrent
await Task.CompletedTask;
}
/// <summary>
/// Sends an authenticated request to the µTorrent API
/// Handles automatic authentication and retry logic
/// </summary>
/// <param name="request">The request to send</param>
/// <returns>Raw JSON response from the API</returns>
private async Task<string> SendAuthenticatedRequestAsync(UTorrentRequest request)
{
try
{
// Get valid token and cookie from cache-aware authenticator
var token = await _authenticator.GetValidTokenAsync();
var guidCookie = await _authenticator.GetValidGuidCookieAsync();
request.Token = token;
return await _httpService.SendRawRequestAsync(request, guidCookie);
}
catch (UTorrentAuthenticationException)
{
// On authentication failure, invalidate cache and retry once
try
{
await _authenticator.InvalidateSessionAsync();
var token = await _authenticator.GetValidTokenAsync();
var guidCookie = await _authenticator.GetValidGuidCookieAsync();
request.Token = token;
return await _httpService.SendRawRequestAsync(request, guidCookie);
}
catch (Exception ex)
{
_logger.LogError(ex, "Authentication retry failed for µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentAuthenticationException($"Authentication retry failed: {ex.Message}", ex);
}
}
}
}

View File

@@ -0,0 +1,181 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Request;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of HTTP service for µTorrent Web UI API communication
/// Handles low-level HTTP requests and authentication token retrieval
/// </summary>
public class UTorrentHttpService : IUTorrentHttpService
{
private readonly HttpClient _httpClient;
private readonly DownloadClientConfig _config;
private readonly ILogger<UTorrentHttpService> _logger;
// Regex pattern to extract token from µTorrent Web UI HTML
private static readonly Regex TokenRegex = new(@"<div[^>]*id=['""]token['""][^>]*>([^<]+)</div>",
RegexOptions.IgnoreCase);
public UTorrentHttpService(
HttpClient httpClient,
DownloadClientConfig config,
ILogger<UTorrentHttpService> logger)
{
_httpClient = httpClient;
_config = config;
_logger = logger;
}
/// <inheritdoc/>
public async Task<string> SendRawRequestAsync(UTorrentRequest request, string guidCookie)
{
if (string.IsNullOrEmpty(guidCookie))
{
throw new UTorrentAuthenticationException("GUID cookie is required for API requests");
}
try
{
var queryString = request.ToQueryString();
UriBuilder uriBuilder = new UriBuilder(_config.Url)
{
Query = queryString
};
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/gui/";
var httpRequest = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
httpRequest.Headers.Add("Cookie", guidCookie);
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}"));
httpRequest.Headers.Add("Authorization", $"Basic {credentials}");
var response = await _httpClient.SendAsync(httpRequest);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("UTorrent API request failed: {StatusCode} - {Content}", response.StatusCode, errorContent);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UTorrentAuthenticationException("Authentication failed - invalid credentials or token expired");
}
throw new UTorrentException($"HTTP request failed: {response.StatusCode} - {errorContent}");
}
var jsonResponse = await response.Content.ReadAsStringAsync();
if (string.IsNullOrEmpty(jsonResponse))
{
throw new UTorrentException("Empty response received from µTorrent API");
}
return jsonResponse;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request failed for UTorrent API: {Action}", request.Action);
throw new UTorrentException($"HTTP request failed: {ex.Message}", ex);
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "HTTP request timeout for UTorrent API: {Action}", request.Action);
throw new UTorrentException($"HTTP request timeout: {ex.Message}", ex);
}
}
/// <inheritdoc/>
public async Task<(string token, string guidCookie)> GetTokenAndCookieAsync()
{
try
{
UriBuilder uriBuilder = new UriBuilder(_config.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/gui/token.html";
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}"));
var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
request.Headers.Add("Authorization", $"Basic {credentials}");
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Failed to retrieve authentication token: {StatusCode} - {Content}",
response.StatusCode, errorContent);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UTorrentAuthenticationException("Authentication failed - check username and password");
}
throw new UTorrentException($"Token retrieval failed: {response.StatusCode} - {errorContent}");
}
var html = await response.Content.ReadAsStringAsync();
// Extract token from HTML
var tokenMatch = TokenRegex.Match(html);
if (!tokenMatch.Success)
{
_logger.LogError("Failed to extract token from HTML response: {Html}", html);
throw new UTorrentAuthenticationException("Failed to extract authentication token from response");
}
var token = tokenMatch.Groups[1].Value;
// Extract GUID from cookies
var guidCookie = ExtractGuidCookie(response.Headers);
if (string.IsNullOrEmpty(guidCookie))
{
throw new UTorrentAuthenticationException("Failed to extract GUID cookie from response");
}
return (token, guidCookie);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request failed while retrieving authentication token");
throw new UTorrentAuthenticationException($"Token retrieval failed: {ex.Message}", ex);
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "HTTP request timeout while retrieving authentication token");
throw new UTorrentAuthenticationException($"Token retrieval timeout: {ex.Message}", ex);
}
}
/// <summary>
/// Extracts the GUID cookie from HTTP response headers
/// </summary>
/// <param name="headers">HTTP response headers</param>
/// <returns>GUID cookie string or empty string if not found</returns>
private static string ExtractGuidCookie(System.Net.Http.Headers.HttpResponseHeaders headers)
{
if (!headers.TryGetValues("Set-Cookie", out var cookies))
{
return string.Empty;
}
foreach (var cookie in cookies)
{
if (cookie.Contains("GUID="))
{
return cookie.Split(';')[0]; // Get just the GUID part, ignore expires, path, etc.
}
}
return string.Empty;
}
}

View File

@@ -0,0 +1,96 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Factory for creating type-safe UTorrent API requests
/// Provides specific methods for each supported API endpoint
/// </summary>
public static class UTorrentRequestFactory
{
/// <summary>
/// Creates a request to get the list of all torrents
/// </summary>
/// <returns>Request for torrent list API call</returns>
public static UTorrentRequest CreateTorrentListRequest()
{
return UTorrentRequest.Create("list=1", string.Empty);
}
/// <summary>
/// Creates a request to get files for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for file list API call</returns>
public static UTorrentRequest CreateFileListRequest(string hash)
{
return UTorrentRequest.Create("action=getfiles", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to get properties for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for properties API call</returns>
public static UTorrentRequest CreatePropertiesRequest(string hash)
{
return UTorrentRequest.Create("action=getprops", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to get all labels
/// </summary>
/// <returns>Request for label list API call</returns>
public static UTorrentRequest CreateLabelListRequest()
{
return UTorrentRequest.Create("list=1", string.Empty);
}
/// <summary>
/// Creates a request to remove a torrent and its data
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for remove torrent with data API call</returns>
public static UTorrentRequest CreateRemoveTorrentWithDataRequest(string hash)
{
return UTorrentRequest.Create("action=removedatatorrent", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to set file priorities for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="fileIndexes"></param>
/// <param name="filePriority"></param>
/// <returns>Request for set file priorities API call</returns>
public static UTorrentRequest CreateSetFilePrioritiesRequest(string hash, List<int> fileIndexes, int filePriority)
{
var request = UTorrentRequest.Create("action=setprio", string.Empty)
.WithParameter("hash", hash)
.WithParameter("p", filePriority.ToString());
foreach (int fileIndex in fileIndexes)
{
request.WithParameter("f", fileIndex.ToString());
}
return request;
}
/// <summary>
/// Creates a request to set a torrent's label
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="label">Label to set</param>
/// <returns>Request for set label API call</returns>
public static UTorrentRequest CreateSetLabelRequest(string hash, string label)
{
return UTorrentRequest.Create("action=setprops", string.Empty)
.WithParameter("hash", hash)
.WithParameter("s", "label")
.WithParameter("v", label);
}
}

View File

@@ -0,0 +1,237 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Exceptions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of µTorrent response parser
/// Handles endpoint-specific parsing of API responses with proper error handling
/// </summary>
public class UTorrentResponseParser : IUTorrentResponseParser
{
private readonly ILogger<UTorrentResponseParser> _logger;
public UTorrentResponseParser(ILogger<UTorrentResponseParser> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public TorrentListResponse ParseTorrentList(string json)
{
try
{
var response = JsonConvert.DeserializeObject<TorrentListResponse>(json);
if (response == null)
{
throw new UTorrentParsingException("Failed to deserialize torrent list response", json);
}
// Parse torrents
if (response.TorrentsRaw != null)
{
foreach (var data in response.TorrentsRaw)
{
if (data is { Length: >= 27 })
{
response.Torrents.Add(new UTorrentItem
{
Hash = data[0].ToString() ?? string.Empty,
Status = Convert.ToInt32(data[1]),
Name = data[2].ToString() ?? string.Empty,
Size = Convert.ToInt64(data[3]),
Progress = Convert.ToInt32(data[4]),
Downloaded = Convert.ToInt64(data[5]),
Uploaded = Convert.ToInt64(data[6]),
RatioRaw = Convert.ToInt32(data[7]),
UploadSpeed = Convert.ToInt32(data[8]),
DownloadSpeed = Convert.ToInt32(data[9]),
ETA = Convert.ToInt32(data[10]),
Label = data[11].ToString() ?? string.Empty,
PeersConnected = Convert.ToInt32(data[12]),
PeersInSwarm = Convert.ToInt32(data[13]),
SeedsConnected = Convert.ToInt32(data[14]),
SeedsInSwarm = Convert.ToInt32(data[15]),
Availability = Convert.ToInt32(data[16]),
QueueOrder = Convert.ToInt32(data[17]),
Remaining = Convert.ToInt64(data[18]),
DownloadUrl = data[19].ToString() ?? string.Empty,
RssFeedUrl = data[20].ToString() ?? string.Empty,
StatusMessage = data[21].ToString() ?? string.Empty,
StreamId = data[22].ToString() ?? string.Empty,
DateAdded = Convert.ToInt64(data[23]),
DateCompleted = Convert.ToInt64(data[24]),
AppUpdateUrl = data[25].ToString() ?? string.Empty,
SavePath = data[26].ToString() ?? string.Empty
});
}
}
}
// Parse labels
if (response.LabelsRaw != null)
{
foreach (var labelData in response.LabelsRaw)
{
if (labelData is { Length: > 0 })
{
var labelName = labelData[0].ToString();
if (!string.IsNullOrEmpty(labelName))
{
response.Labels.Add(labelName);
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse torrent list JSON response");
throw new UTorrentParsingException($"Failed to parse torrent list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing torrent list response");
throw new UTorrentParsingException($"Unexpected error parsing torrent list response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public FileListResponse ParseFileList(string json)
{
try
{
var rawResponse = JsonConvert.DeserializeObject<FileListResponse>(json);
if (rawResponse == null)
{
throw new UTorrentParsingException("Failed to deserialize file list response", json);
}
var response = new FileListResponse();
// Parse files from the nested array structure
if (rawResponse.FilesRaw is { Length: >= 2 })
{
response.Hash = rawResponse.FilesRaw[0].ToString() ?? string.Empty;
if (rawResponse.FilesRaw[1] is JArray jArray)
{
foreach (var jToken in jArray)
{
if (jToken is JArray fileArray)
{
var fileData = fileArray.ToObject<object[]>() ?? Array.Empty<object>();
if (fileData.Length >= 4)
{
response.Files.Add(new UTorrentFile
{
Name = fileData[0]?.ToString() ?? string.Empty,
Size = Convert.ToInt64(fileData[1]),
Downloaded = Convert.ToInt64(fileData[2]),
Priority = Convert.ToInt32(fileData[3]),
});
}
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse file list JSON response");
throw new UTorrentParsingException($"Failed to parse file list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing file list response");
throw new UTorrentParsingException($"Unexpected error parsing file list response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public PropertiesResponse ParseProperties(string json)
{
try
{
var rawResponse = JsonConvert.DeserializeObject<PropertiesResponse>(json);
if (rawResponse == null)
{
throw new UTorrentParsingException("Failed to deserialize properties response", json);
}
var response = new PropertiesResponse();
// Parse properties from the array structure
if (rawResponse.PropertiesRaw is { Length: > 0 })
{
response.Properties = JsonConvert.DeserializeObject<UTorrentProperties>(rawResponse.PropertiesRaw.FirstOrDefault()?.ToString());
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse properties JSON response");
throw new UTorrentParsingException($"Failed to parse properties response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing properties response");
throw new UTorrentParsingException($"Unexpected error parsing properties response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public LabelListResponse ParseLabelList(string json)
{
try
{
var response = JsonConvert.DeserializeObject<LabelListResponse>(json);
if (response == null)
{
throw new UTorrentParsingException("Failed to deserialize label list response", json);
}
// Parse labels
if (response.LabelsRaw != null)
{
foreach (var labelData in response.LabelsRaw)
{
if (labelData is { Length: > 0 })
{
var labelName = labelData[0]?.ToString();
if (!string.IsNullOrEmpty(labelName))
{
response.Labels.Add(labelName);
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse label list JSON response");
throw new UTorrentParsingException($"Failed to parse label list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing label list response");
throw new UTorrentParsingException($"Unexpected error parsing label list response: {ex.Message}", json, ex);
}
}
}

View File

@@ -0,0 +1,128 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// µTorrent download service implementation
/// Provides business logic layer on top of UTorrentClient
/// </summary>
public partial class UTorrentService : DownloadService, IUTorrentService
{
private readonly UTorrentClient _client;
public UTorrentService(
ILogger<UTorrentService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
EventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
ILoggerFactory loggerFactory
) : base(
logger, cache,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
)
{
// Create the new layered client with dependency injection
var httpService = new UTorrentHttpService(_httpClient, downloadClientConfig, loggerFactory.CreateLogger<UTorrentHttpService>());
var authenticator = new UTorrentAuthenticator(
cache,
httpService,
downloadClientConfig,
loggerFactory.CreateLogger<UTorrentAuthenticator>()
);
var responseParser = new UTorrentResponseParser(loggerFactory.CreateLogger<UTorrentResponseParser>());
_client = new UTorrentClient(
downloadClientConfig,
authenticator,
httpService,
responseParser,
loggerFactory.CreateLogger<UTorrentClient>()
);
}
public override void Dispose()
{
}
/// <summary>
/// Authenticates with µTorrent Web UI
/// </summary>
public override async Task LoginAsync()
{
try
{
var loginSuccess = await _client.LoginAsync();
if (!loginSuccess)
{
throw new InvalidOperationException("Failed to authenticate with µTorrent Web UI");
}
_logger.LogDebug("Successfully logged in to µTorrent client {clientId}", _downloadClientConfig.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to login to µTorrent client {clientId}", _downloadClientConfig.Id);
throw;
}
}
/// <summary>
/// Performs health check for µTorrent service
/// </summary>
public override async Task<HealthCheckResult> HealthCheckAsync()
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Test authentication and basic connectivity
await _client.LoginAsync();
// Test API connectivity with a simple request
var connectionOk = await _client.TestConnectionAsync();
if (!connectionOk)
{
throw new InvalidOperationException("API connection test failed");
}
_logger.LogDebug("Health check: Successfully connected to µTorrent client {clientId}", _downloadClientConfig.Id);
stopwatch.Stop();
return new HealthCheckResult
{
IsHealthy = true,
ResponseTime = stopwatch.Elapsed
};
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Health check failed for µTorrent client {clientId}", _downloadClientConfig.Id);
return new HealthCheckResult
{
IsHealthy = false,
ResponseTime = stopwatch.Elapsed,
ErrorMessage = ex.Message
};
}
}
}

View File

@@ -0,0 +1,115 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
hash = hash.ToLowerInvariant();
UTorrentItem? download = await _client.GetTorrentAsync(hash);
BlockFilesResult result = new();
if (download?.Hash is null)
{
_logger.LogDebug("Failed to find torrent {hash} in the download client", hash);
return result;
}
result.Found = true;
var properties = await _client.GetTorrentPropertiesAsync(hash);
result.IsPrivate = properties.IsPrivate;
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && result.IsPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result;
}
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(hash);
if (files?.Count is null or 0)
{
_logger.LogDebug("skip files check | no files found | {name}", download.Name);
return result;
}
List<int> fileIndexes = new(files.Count);
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
for (int i = 0; i < files.Count; i++)
{
if (IsDefinitelyMalware(files[i].Name))
{
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
var file = files[i];
if (file.Priority == 0) // Already skipped
{
totalUnwantedFiles++;
continue;
}
if (file.Priority != 0 && !_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
fileIndexes.Add(i);
_logger.LogInformation("unwanted file found | {file}", file.Name);
}
}
if (fileIndexes.Count is 0)
{
return result;
}
_logger.LogDebug("changing priorities | torrent {hash}", hash);
if (totalUnwantedFiles == files.Count)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, fileIndexes);
return result;
}
protected virtual async Task ChangeFilesPriority(string hash, List<int> fileIndexes)
{
await _client.SetFilesPriorityAsync(hash, fileIndexes, 0);
}
}

View File

@@ -0,0 +1,234 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
public override async Task<List<object>?> GetSeedingDownloads()
{
var torrents = await _client.GetTorrentsAsync();
return torrents
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => x.IsSeeding())
.Cast<object>()
.ToList();
}
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
downloads
?.Cast<UTorrentItem>()
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
downloads
?.Cast<UTorrentItem>()
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
/// <inheritdoc/>
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
{
if (downloads?.Count is null or 0)
{
return;
}
foreach (UTorrentItem download in downloads.Cast<UTorrentItem>())
{
if (string.IsNullOrEmpty(download.Hash))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
var properties = await _client.GetTorrentPropertiesAsync(download.Hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
CleanCategory? category = categoriesToClean
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
continue;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!downloadCleanerConfig.DeletePrivate && properties.IsPrivate)
{
_logger.LogDebug("skip | download is private | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
TimeSpan? seedingTime = download.SeedingTime;
if (seedingTime == null)
{
_logger.LogDebug("skip | could not determine seeding time | {name}", download.Name);
continue;
}
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime.Value, category);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
download.Name
);
await _eventPublisher.PublishDownloadCleaned(download.Ratio, seedingTime.Value, category.Name, result.Reason);
}
}
public override async Task CreateCategoryAsync(string name)
{
var existingLabels = await _client.GetLabelsAsync();
if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase))
{
return;
}
_logger.LogDebug("Creating category {name}", name);
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
}
foreach (UTorrentItem download in downloads.Cast<UTorrentItem>())
{
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Label))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
var properties = await _client.GetTorrentPropertiesAsync(download.Hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(download.Hash);
bool hasHardlinks = false;
foreach (var file in files ?? [])
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/']));
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
if (hardlinkCount < 0)
{
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
hasHardlinks = true;
break;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
continue;
}
//TODO change label on download object
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(download.Label, downloadCleanerConfig.UnlinkedTargetCategory);
_logger.LogInformation("category changed for {name}", download.Name);
download.Label = downloadCleanerConfig.UnlinkedTargetCategory;
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash)
{
hash = hash.ToLowerInvariant();
await _client.RemoveTorrentsAsync([hash]);
}
protected async Task CreateLabel(string name)
{
await UTorrentClient.CreateLabel(name);
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetTorrentLabelAsync(hash, newLabel);
}
}

View File

@@ -0,0 +1,174 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
List<UTorrentFile>? files = null;
DownloadCheckResult result = new();
UTorrentItem? download = await _client.GetTorrentAsync(hash);
if (download?.Hash is null)
{
_logger.LogDebug("Failed to find torrent {hash} in the download client", hash);
return result;
}
result.Found = true;
var properties = await _client.GetTorrentPropertiesAsync(hash);
result.IsPrivate = properties.IsPrivate;
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
try
{
files = await _client.GetTorrentFilesAsync(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Failed to get files for torrent {hash} in the download client", hash);
}
bool shouldRemove = files?.Count > 0;
foreach (var file in files ?? [])
{
if (file.Priority > 0) // 0 = skip, >0 = wanted
{
shouldRemove = false;
break;
}
}
if (shouldRemove)
{
// remove if all files are unwanted
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
}
// remove if download is stuck
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, result.IsPrivate);
return result;
}
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(UTorrentItem torrent, bool isPrivate)
{
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(torrent, isPrivate);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(torrent, isPrivate);
}
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(UTorrentItem download, bool isPrivate)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogTrace("skip slow check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (!download.IsDownloading())
{
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.StatusMessage, download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
{
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
return (false, DeleteReason.None);
}
ByteSize minSpeed = queueCleanerConfig.Slow.MinSpeedByteSize;
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(queueCleanerConfig.Slow.MaxTime);
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.ETA);
return await CheckIfSlow(
download.Hash,
download.Name,
minSpeed,
currentSpeed,
maxTime,
currentTime
);
}
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(UTorrentItem download, bool isPrivate)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
{
_logger.LogDebug("skip stalled check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (!download.IsDownloading())
{
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", download.StatusMessage, download.Name);
return (false, DeleteReason.None);
}
if (download.DateCompleted > 0)
{
_logger.LogTrace("skip stalled check | download is completed | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed > 0 || download.ETA > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", download.Name);
return (false, DeleteReason.None);
}
ResetStalledStrikesOnProgress(download.Hash, download.Downloaded);
return (await _striker.StrikeAndCheckLimit(download.Hash, download.Name, queueCleanerConfig.Stalled.MaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
}
}

View File

@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// µTorrent status bitfield constants
/// Based on the µTorrent Web UI API documentation
/// </summary>
public static class UTorrentStatus
{
public const int Started = 1; // 1 << 0
public const int Checking = 2; // 1 << 1
public const int StartAfterCheck = 4; // 1 << 2
public const int Checked = 8; // 1 << 3
public const int Error = 16; // 1 << 4
public const int Paused = 32; // 1 << 5
public const int Queued = 64; // 1 << 6
public const int Loaded = 128; // 1 << 7
}

View File

@@ -15,4 +15,10 @@ public static class CacheKeys
public static string IgnoredDownloads(string name) => $"{name}_ignored";
public static string DownloadMarkedForRemoval(string hash, Uri url) => $"remove_{hash.ToLowerInvariant()}_{url}";
public static class UTorrent
{
public static string GetAuthTokenKey(string clientId) => $"utorrent:auth:token:{clientId}";
public static string GetGuidCookieKey(string clientId) => $"utorrent:auth:cookie:{clientId}";
}
}

View File

@@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,6 @@
namespace Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
namespace Cleanuparr.Shared.Helpers;
public static class Constants
{
@@ -7,4 +9,9 @@ public static class Constants
public static readonly TimeSpan CacheLimitBuffer = TimeSpan.FromHours(2);
public const string HttpClientWithRetryName = "retry";
public static readonly MemoryCacheEntryOptions DefaultCacheEntryOptions = new()
{
SlidingExpiration = TimeSpan.FromMinutes(10)
};
}

View File

@@ -84,7 +84,7 @@ export class DocumentationService {
'download-client': {
'enabled': 'enable-download-client',
'name': 'client-name',
'type': 'client-type',
'typeName': 'client-type',
'host': 'client-host',
'urlBase': 'url-base-path',
'username': 'username',

View File

@@ -793,7 +793,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
private showEnableConfirmationDialog(): void {
this.confirmationService.confirm({
header: 'Enable Download Cleaner',
message: 'To avoid affecting items that are awaiting to be imported, please ensure that your Sonarr, Radarr, and Lidarr instances have been properly configured prior to enabling the Download Cleaner.<br/><br/>Are you sure you want to proceed?',
message: 'To avoid affecting items that are awaiting to be imported, please ensure that your Sonarr, Radarr, and Lidarr instances have been configured prior to enabling the Download Cleaner.<br/><br/>Are you sure you want to proceed?',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-check',
rejectIcon: 'pi pi-times',

View File

@@ -147,21 +147,21 @@
<div class="field">
<label for="client-type">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('type')"
(click)="openFieldDocs('typeName')"
pTooltip="Click for documentation"></i>
Client Type *
</label>
<p-select
id="client-type"
formControlName="type"
[options]="clientTypeOptions"
formControlName="typeName"
[options]="typeNameOptions"
optionLabel="label"
optionValue="value"
placeholder="Select client type"
appendTo="body"
class="w-full"
></p-select>
<small *ngIf="hasError(clientForm, 'type', 'required')" class="p-error">Client type is required</small>
<small *ngIf="hasError(clientForm, 'typeName', 'required')" class="p-error">Client type is required</small>
</div>
<ng-container>

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { DownloadClientConfigStore } from "./download-client-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { DownloadClientType } from "../../shared/models/enums";
import { DownloadClientType, DownloadClientTypeName } from "../../shared/models/enums";
import { DocumentationService } from "../../core/services/documentation.service";
// PrimeNG Components
@@ -56,11 +56,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
editingClient: ClientConfig | null = null;
// Download client type options
clientTypeOptions = [
{ label: "qBittorrent", value: DownloadClientType.QBittorrent },
{ label: "Deluge", value: DownloadClientType.Deluge },
{ label: "Transmission", value: DownloadClientType.Transmission },
];
typeNameOptions: { label: string, value: DownloadClientTypeName }[] = [];
// Clean up subscriptions
private destroy$ = new Subject<void>();
@@ -89,7 +85,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
// Initialize client form for modal
this.clientForm = this.formBuilder.group({
name: ['', Validators.required],
type: [null, Validators.required],
typeName: [null, Validators.required],
host: ['', [Validators.required, this.uriValidator.bind(this)]],
username: [''],
password: [''],
@@ -97,11 +93,19 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
enabled: [true]
});
// Initialize type name options
for (const key of Object.keys(DownloadClientTypeName)) {
this.typeNameOptions.push({
label: key,
value: DownloadClientTypeName[key as keyof typeof DownloadClientTypeName]
});
}
// Load Download Client config data
this.downloadClientStore.loadConfig();
// Setup client type change handler
this.clientForm.get('type')?.valueChanges
this.clientForm.get('typeName')?.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.onClientTypeChange();
@@ -184,14 +188,9 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
this.modalMode = 'edit';
this.editingClient = client;
// Map backend type to frontend type
const frontendType = client.typeName
? this.mapClientTypeFromBackend(client.typeName)
: client.type;
this.clientForm.patchValue({
name: client.name,
type: frontendType,
typeName: client.typeName,
host: client.host,
username: client.username,
password: client.password,
@@ -222,28 +221,27 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
}
const formValue = this.clientForm.value;
const mappedType = this.mapClientTypeForBackend(formValue.type);
const clientData: CreateDownloadClientDto = {
name: formValue.name,
typeName: mappedType.typeName,
type: mappedType.type,
host: formValue.host,
username: formValue.username,
password: formValue.password,
urlBase: formValue.urlBase,
enabled: formValue.enabled
};
if (this.modalMode === 'add') {
const clientData: CreateDownloadClientDto = {
name: formValue.name,
type: this.mapTypeNameToType(formValue.typeName),
typeName: formValue.typeName,
host: formValue.host,
username: formValue.username,
password: formValue.password,
urlBase: formValue.urlBase,
enabled: formValue.enabled
};
this.downloadClientStore.createClient(clientData);
} else if (this.editingClient) {
// For updates, create a proper ClientConfig object
const clientConfig: ClientConfig = {
id: this.editingClient.id!,
id: this.editingClient.id,
name: formValue.name,
type: formValue.type, // Keep the frontend enum type
typeName: mappedType.typeName,
type: this.mapTypeNameToType(formValue.typeName),
typeName: formValue.typeName,
host: formValue.host,
username: formValue.username,
password: formValue.password,
@@ -325,42 +323,25 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
}
/**
* Map frontend client type to backend TypeName and Type
* Map typeName to type category
*/
private mapClientTypeForBackend(frontendType: DownloadClientType): { typeName: string, type: string } {
switch (frontendType) {
case DownloadClientType.QBittorrent:
return { typeName: 'qBittorrent', type: 'Torrent' };
case DownloadClientType.Deluge:
return { typeName: 'Deluge', type: 'Torrent' };
case DownloadClientType.Transmission:
return { typeName: 'Transmission', type: 'Torrent' };
private mapTypeNameToType(typeName: DownloadClientTypeName): DownloadClientType {
switch (typeName) {
case DownloadClientTypeName.qBittorrent:
case DownloadClientTypeName.Deluge:
case DownloadClientTypeName.Transmission:
case DownloadClientTypeName.uTorrent:
return DownloadClientType.Torrent;
default:
return { typeName: 'QBittorrent', type: 'Torrent' };
throw new Error(`Unknown client type name: ${typeName}`);
}
}
/**
* Map backend TypeName to frontend client type
*/
private mapClientTypeFromBackend(backendTypeName: string): DownloadClientType {
switch (backendTypeName) {
case 'QBittorrent':
return DownloadClientType.QBittorrent;
case 'Deluge':
return DownloadClientType.Deluge;
case 'Transmission':
return DownloadClientType.Transmission;
default:
return DownloadClientType.QBittorrent;
}
}
/**
* Handle client type changes to update validation
*/
onClientTypeChange(): void {
const clientType = this.clientForm.get('type')?.value;
const clientTypeName = this.clientForm.get('typeName')?.value;
const hostControl = this.clientForm.get('host');
const usernameControl = this.clientForm.get('username');
const urlBaseControl = this.clientForm.get('urlBase');
@@ -373,13 +354,13 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
]);
// Clear username value and remove validation for Deluge
if (clientType === DownloadClientType.Deluge) {
if (clientTypeName === DownloadClientTypeName.Deluge) {
usernameControl.setValue('');
usernameControl.clearValidators();
}
// Set default URL base for Transmission
if (clientType === DownloadClientType.Transmission) {
if (clientTypeName === DownloadClientTypeName.Transmission) {
urlBaseControl.setValue('transmission');
}
@@ -392,19 +373,15 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
* Check if username field should be shown (hidden for Deluge)
*/
shouldShowUsernameField(): boolean {
const clientType = this.clientForm.get('type')?.value;
return clientType !== DownloadClientType.Deluge;
const clientTypeName = this.clientForm.get('typeName')?.value;
return clientTypeName !== DownloadClientTypeName.Deluge;
}
/**
* Get client type label for display
*/
getClientTypeLabel(client: ClientConfig): string {
const frontendType = client.typeName
? this.mapClientTypeFromBackend(client.typeName)
: client.type;
const option = this.clientTypeOptions.find(opt => opt.value === frontendType);
const option = this.typeNameOptions.find(opt => opt.value === client.typeName);
return option?.label || 'Unknown';
}

View File

@@ -1,4 +1,4 @@
import { DownloadClientType } from './enums';
import { DownloadClientType, DownloadClientTypeName } from './enums';
/**
* Represents a download client configuration object
@@ -37,7 +37,7 @@ export interface ClientConfig {
/**
* Type name of download client (backend enum)
*/
typeName?: string;
typeName: DownloadClientTypeName;
/**
* Host address for the download client
@@ -73,16 +73,16 @@ export interface CreateDownloadClientDto {
* Friendly name for this client
*/
name: string;
/**
* Type of download client (backend enum)
*/
type: DownloadClientType;
/**
* Type name of download client (backend enum)
*/
typeName: string;
/**
* Type of download client (backend enum)
*/
type: string;
typeName: DownloadClientTypeName;
/**
* Host address for the download client

View File

@@ -1,8 +1,11 @@
/**
* Download client type enum matching backend DownloadClientType
*/
export enum DownloadClientType {
QBittorrent = 0,
Deluge = 1,
Transmission = 2,
Torrent = "Torrent",
Usenet = "Usenet",
}
export enum DownloadClientTypeName {
qBittorrent = "qBittorrent",
Deluge = "Deluge",
Transmission = "Transmission",
uTorrent = "uTorrent",
}

View File

@@ -18,8 +18,8 @@ Cleanuparr integrates with popular *arr applications and download clients for co
| **Category** | **Application** | **Integration** |
|--------------|-----------------|-----------------|
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr | Full API integration for queue monitoring and search triggers |
| **Download Clients** | qBittorrent, Deluge, Transmission | Complete download management and monitoring |
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr, Whisparr | Full API integration for queue monitoring and search triggers |
| **Download Clients** | qBittorrent, Deluge, Transmission, µTorrent | Complete download management and monitoring |
</div>

View File

@@ -42,7 +42,7 @@ This is a detailed explanation of how the recurring cleanup jobs work.
icon="🧹"
>
- Run every 5 minutes (or configured cron, or right after `Content Blocker`).
- Run every 5 minutes (or configured cron).
- Process all items in the *arr queue.
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading**, **failed to be imported** or **slow**.
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.

View File

@@ -140,7 +140,7 @@ Controls how the blocklist is interpreted:
- **Whitelist**: Only files matching patterns in the list will be allowed.
:::tip
[This blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist) can be used for Sonarr and Radarr.
[This blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive), [this whitelist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist) and [this whitelist with subtitles](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist_with_subtitles) can be used for Sonarr and Radarr.
:::
</ConfigSection>

View File

@@ -9,7 +9,7 @@ import {
# Download Client
Configure download client connections for torrents and usenet. Cleanuparr supports qBittorrent, Deluge, and Transmission download clients.
Configure download client connections for torrents. Cleanuparr supports qBittorrent, Deluge, Transmission and µTorrent download clients.
<div className={styles.documentationPage}>

View File

@@ -163,6 +163,7 @@ Downloads matching these patterns will be ignored during all cleaning operations
- qBittorrent tag or category
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- torrent tracker domain
**Examples:**

View File

@@ -45,6 +45,20 @@ services:
- TZ=Etc/UTC
```
## Unraid
Use the Unraid template available in the Community Applications plugin. If the template is not yet available, you can manually add using the above Docker Compose configuration or use this guide to create a custom template:
1. Download the template from here: https://github.com/Cleanuparr/unraid/blob/main/templates/Cleanuparr.xml
2. Rename the downloaded file to 'my-cleanuparr.xml'
3. Drop it in the `/boot/config/plugins/dockerMan/templates-user/` folder of your Unraid machine
4. Go to the Docker tab of the Unraid webui
5. Click on Add Container
6. From the `Template` dropdown menu, select `cleanuparr`
7. Set the repository to `ghcr.io/cleanuparr/cleanuparr:latest`
8. Most other settings can be left at default, with the exception of the downloads folder which should be mapped to where your *arr stack / torrent client downloads its files to
## 🖥️ Other Installation Methods
- **Windows**: Download the installer from [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)

View File

@@ -1,201 +0,0 @@
import React, { useEffect, useRef } from "react";
import ReactMarkdown from 'react-markdown';
import { useLocation } from "@docusaurus/router";
import Admonition from "../Admonition";
export type DescriptionContent =
| string
| {
type: "code" | "list";
title: string;
content: string | string[];
};
export interface EnvVarProps {
name: string;
description: DescriptionContent[];
type: string;
reference?: string;
required?: boolean | string;
defaultValue: string;
defaultValueComment?: string;
examples?: string[];
acceptedValues?: string[];
children?: React.ReactNode;
notes?: string[];
important?: string[];
warnings?: string[];
}
interface EnvVarsProps {
vars: EnvVarProps[];
}
export default function EnvVars({ vars }: EnvVarsProps) {
return vars.map((env) => <EnvVar key={env.name} env={env} />);
}
function EnvVar({ env }: { env: EnvVarProps }) {
const ref = useRef<HTMLDivElement>(null);
const location = useLocation();
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const queryKeys = Array.from(searchParams.keys());
const matched = queryKeys.find(
(key) => key.toLowerCase() === env.name.toLowerCase()
);
if (matched && ref.current) {
// Scroll to the variable
ref.current.scrollIntoView({ behavior: "smooth", block: "start" });
// Add highlight effect
ref.current.classList.add("env-var-highlight");
setTimeout(() => {
ref.current.classList.add("highlight-removing");
}, 2000);
setTimeout(() => {
ref.current.classList.remove("env-var-highlight", "highlight-removing");
}, 3000);
}
}, [location.search, env.name]);
const renderDescriptionContent = (
content: DescriptionContent,
index: number
) => {
if (typeof content === "string") {
return <ReactMarkdown components={{ p: ({ children }) => <div>{children}</div> }}>{content}</ReactMarkdown>;
}
switch (content.type) {
case "code":
return (
<section>
{content.title && <strong>{content.title}</strong>}
<br />
<pre key={index}>
{content.content}
</pre>
</section>
);
case "list":
return (
<section>
{content.title && <strong>{content.title}</strong>}
<br />
<ul key={index}>
{(Array.isArray(content.content)
? content.content
: [content.content]
).map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</section>
);
default:
return null;
}
};
const renderAdmonition = (type: "important" | "warning" | "note", items: string[]) => {
if (!items || items.length === 0) return null;
return (
<Admonition type={type}>
<ul>
{items.map((item, idx) => (
<li key={idx}>
<ReactMarkdown components={{ p: ({ children }) => <>{children}</> }}>
{item}
</ReactMarkdown>
</li>
))}
</ul>
</Admonition>
);
};
return (
<>
<div id={env.name} ref={ref} className="env-var-block">
<h3>
<code>{env.name}</code>
</h3>
{env.description.map((desc, index) =>
renderDescriptionContent(desc, index)
)}
{env.required !== undefined && (
<section>
<strong>Required: </strong>
{typeof env.required === "boolean"
? env.required
? "Yes"
: "No"
: env.required}
</section>
)}
{env.type !== undefined && (
<section>
<strong>Type: </strong>
{env.type}
</section>
)}
{env.defaultValue !== undefined && (
<section>
<strong>Default value: </strong>
<code>{env.defaultValue}</code> {env.defaultValueComment !== undefined && (`(${env.defaultValueComment})`)}
</section>
)}
{env.reference !== undefined && (
<section>
<strong>Reference: </strong>
<ReactMarkdown
components={{
p: ({ children }) => <>{children}</>, // No wrapping <p> tag
}}
>
{`[Quartz.NET](${env.reference})`}
</ReactMarkdown>
</section>
)}
{env.acceptedValues && env.acceptedValues.length > 0 && (
<section>
<strong>Accepted values:</strong>
<ul>
{env.acceptedValues.map((example, index) => (
<li key={index}>
<code>{example}</code>
</li>
))}
</ul>
</section>
)}
{env.examples && env.examples.length > 0 && (
<section>
<strong>Examples:</strong>
<ul>
{env.examples.map((example, index) => (
<li key={index}>
<code>{example}</code>
</li>
))}
</ul>
</section>
)}
{env.notes && renderAdmonition("note", env.notes)}
{env.important && renderAdmonition("important", env.important)}
{env.warnings && renderAdmonition("warning", env.warnings)}
<div style={{ marginTop: "0.5rem" }}>{env.children}</div>
</div>
<hr />
</>
);
}

View File

@@ -1,100 +0,0 @@
import React from "react";
import EnvVars, { EnvVarProps } from "./EnvVars";
const settings: EnvVarProps[] = [
{
name: "TZ",
description: [
"The time zone to use."
],
type: "text",
defaultValue: "UTC",
required: false,
examples: ["America/New_York", "Europe/London", "Asia/Tokyo"],
},
{
name: "DRY_RUN",
description: [
"When enabled, simulates irreversible operations (like deletions and notifications) without making actual changes."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "LOGGING__LOGLEVEL",
description: [
"Controls the detail level of application logs."
],
type: "text",
defaultValue: "Information",
required: false,
acceptedValues: ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"],
},
{
name: "LOGGING__FILE__ENABLED",
description: [
"Enables logging to a file."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "LOGGING__FILE__PATH",
description: [
"Directory where log files will be saved."
],
type: "text",
defaultValue: "Empty (file is saved where the app is)",
required: false,
},
{
name: "LOGGING__ENHANCED",
description: [
"Provides more detailed descriptions in logs whenever possible.",
"Will be deprecated in a future version."
],
type: "boolean",
defaultValue: "true",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "HTTP_MAX_RETRIES",
description: [
"The number of times to retry a failed HTTP call.",
"Applies when communicating with *arrs, download clients and other services through HTTP calls."
],
type: "positive integer number",
defaultValue: "0",
required: false,
},
{
name: "HTTP_TIMEOUT",
description: [
"The number of seconds to wait before failing an HTTP call.",
"Applies to calls to *arrs, download clients, and other services."
],
type: "positive integer number",
defaultValue: "100",
required: false,
},
{
name: "HTTP_VALIDATE_CERT",
description: [
"Controls whether to validate SSL certificates for HTTPS connections.",
"Set to `Disabled` to ignore SSL certificate errors."
],
type: "text",
defaultValue: "Enabled",
required: false,
acceptedValues: ["Enabled", "DisabledForLocalAddresses", "Disabled"],
}
];
export default function GeneralSettings() {
return <EnvVars vars={settings} />;
}

View File

@@ -1,35 +0,0 @@
import React from "react";
import EnvVars, { EnvVarProps } from "./EnvVars";
const settings: EnvVarProps[] = [
{
name: "SEARCH_ENABLED",
description: [
"Enabled searching for replacements after a download has been removed from an arr."
],
type: "boolean",
defaultValue: "true",
required: false,
acceptedValues: ["true", "false"],
notes: [
"If you are using [Huntarr](https://github.com/plexguide/Huntarr.io), this setting should be set to false to let Huntarr do the searching.",
]
},
{
name: "SEARCH_DELAY",
description: [
"If searching for replacements is enabled, this setting will delay the searches by the specified number of seconds.",
"This is useful to avoid overwhelming the indexer with too many requests at once.",
],
type: "positive integer number",
defaultValue: "30",
required: false,
important: [
"A lower value or `0` will result in faster searches, but may cause issues such as being rate limited or banned by the indexer.",
]
},
];
export default function SearchSettings() {
return <EnvVars vars={settings} />;
}

View File

@@ -1,37 +0,0 @@
import React from "react";
import EnvVars, { EnvVarProps } from "../EnvVars";
const settings: EnvVarProps[] = [
{
name: "DELUGE__URL",
description: [
"URL of the Deluge instance."
],
type: "text",
defaultValue: "http://localhost:8112",
required: false,
examples: ["http://localhost:8112", "http://192.168.1.100:8112", "https://mydomain.com:8112"],
},
{
name: "DELUGE__URL_BASE",
description: [
"Adds a prefix to the deluge json url, such as `[DELUGE__URL]/[DELUGE__URL_BASE]/json`."
],
type: "text",
defaultValue: "Empty",
required: false,
},
{
name: "DELUGE__PASSWORD",
description: [
"Password for Deluge authentication."
],
type: "text",
defaultValue: "Empty",
required: false,
},
];
export default function DelugeSettings() {
return <EnvVars vars={settings} />;
}

View File

@@ -1,26 +0,0 @@
import React from "react";
import EnvVars, { EnvVarProps } from "../EnvVars";
const settings: EnvVarProps[] = [
{
name: "DOWNLOAD_CLIENT",
description: [
"Specifies which download client is used by *arrs."
],
type: "text",
defaultValue: "none",
required: false,
acceptedValues: ["none", "qbittorrent", "deluge", "transmission", "disabled"],
notes: [
"Only one download client can be enabled at a time. If you have more than one download client, you should deploy multiple instances of Cleanuparr."
],
warnings: [
"When the download client is set to `disabled`, the Queue Cleaner will be able to remove items that are failed to be imported even if there is no download client configured. This means that all downloads, including private ones, will be completely removed.",
"Setting `DOWNLOAD_CLIENT=disabled` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account."
]
},
];
export default function DownloadClientSettings() {
return <EnvVars vars={settings} />;
}

View File

@@ -1,46 +0,0 @@
import React from "react";
import EnvVars, { EnvVarProps } from "../EnvVars";
const settings: EnvVarProps[] = [
{
name: "QBITTORRENT__URL",
description: [
"URL of the qBittorrent instance."
],
type: "text",
defaultValue: "http://localhost:8080",
required: false,
examples: ["http://localhost:8080", "http://192.168.1.100:8080", "https://mydomain.com:8080"],
},
{
name: "QBITTORRENT__URL_BASE",
description: [
"Adds a prefix to the qBittorrent url, such as `[QBITTORRENT__URL]/[QBITTORRENT__URL_BASE]/api`."
],
type: "text",
defaultValue: "Empty",
required: false,
},
{
name: "QBITTORRENT__USERNAME",
description: [
"Username for qBittorrent authentication."
],
type: "text",
defaultValue: "Empty",
required: false,
},
{
name: "QBITTORRENT__PASSWORD",
description: [
"Password for qBittorrent authentication."
],
type: "text",
defaultValue: "Empty",
required: false,
},
];
export default function QBittorrentSettings() {
return <EnvVars vars={settings} />;
}

View File

@@ -1,46 +0,0 @@
import React from "react";
import EnvVars, { EnvVarProps } from "../EnvVars";
const settings: EnvVarProps[] = [
{
name: "TRANSMISSION__URL",
description: [
"URL of the Transmission instance."
],
type: "text",
defaultValue: "http://localhost:9091",
required: false,
examples: ["http://localhost:9091", "http://192.168.1.100:9091", "https://mydomain.com:9091"],
},
{
name: "TRANSMISSION__URL_BASE",
description: [
"Adds a prefix to the Transmission rpc url, such as `[TRANSMISSION__URL]/[TRANSMISSION__URL_BASE]/rpc`."
],
type: "text",
defaultValue: "transmission",
required: false,
},
{
name: "TRANSMISSION__USERNAME",
description: [
"Username for Transmission authentication."
],
type: "text",
defaultValue: "Empty",
required: false,
},
{
name: "TRANSMISSION__PASSWORD",
description: [
"Password for Transmission authentication."
],
type: "text",
defaultValue: "Empty",
required: false,
}
];
export default function TransmissionSettings() {
return <EnvVars vars={settings} />;
}

View File

@@ -1,85 +0,0 @@
import React from "react";
import EnvVars, { EnvVarProps } from "../EnvVars";
const settings: EnvVarProps[] = [
{
name: "APPRISE__URL",
description: [
"[Apprise url](https://github.com/caronc/apprise-api) where to send notifications.",
"The `/notify` endpoint is automatically appended."
],
type: "text",
defaultValue: "Empty",
required: false,
examples: [
"http://localhost:8000"
]
},
{
name: "APPRISE__KEY",
description: ["[Apprise configuration key](https://github.com/caronc/apprise-api?tab=readme-ov-file#screenshots) containing all 3rd party notification providers which Cleanuparr would notify."],
type: "text",
defaultValue: "Empty",
required: false,
},
{
name: "APPRISE__ON_IMPORT_FAILED_STRIKE",
description: [
"Controls whether to notify when an item receives a failed import strike.",
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "APPRISE__ON_STALLED_STRIKE",
description: [
"Controls whether to notify when an item receives a stalled download strike. This includes strikes for being stuck while downloading metadata.",
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "APPRISE__ON_SLOW_STRIKE",
description: [
"Controls whether to notify when an item receives a slow download strike. This includes strikes for having a low download speed or slow estimated finish time.",
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "APPRISE__ON_QUEUE_ITEM_DELETED",
description: ["Controls whether to notify when a queue item is deleted."],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "APPRISE__ON_DOWNLOAD_CLEANED",
description: ["Controls whether to notify when a download is cleaned."],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "APPRISE__ON_CATEGORY_CHANGED",
description: [
"Controls whether to notify when a download's category is changed."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
}
];
export default function AppriseSettings() {
return <EnvVars vars={settings} />;
}

View File

@@ -1,88 +0,0 @@
import React from "react";
import EnvVars, { EnvVarProps } from "../EnvVars";
const settings: EnvVarProps[] = [
{
name: "NOTIFIARR__API_KEY",
description: [
"Notifiarr API key for sending notifications.",
"Requires Notifiarr's [Passthrough](https://notifiarr.wiki/pages/integrations/passthrough/) integration to work."
],
type: "text",
defaultValue: "Empty",
required: false,
},
{
name: "NOTIFIARR__CHANNEL_ID",
description: [
"Discord channel ID where notifications will be sent."
],
type: "text",
defaultValue: "Empty",
required: false,
},
{
name: "NOTIFIARR__ON_IMPORT_FAILED_STRIKE",
description: [
"Controls whether to notify when an item receives a failed import strike."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "NOTIFIARR__ON_STALLED_STRIKE",
description: [
"Controls whether to notify when an item receives a stalled download strike. This includes strikes for being stuck while downloading metadata."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "NOTIFIARR__ON_SLOW_STRIKE",
description: [
"Controls whether to notify when an item receives a slow download strike. This includes strikes for having a low download speed or slow estimated finish time."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "NOTIFIARR__ON_QUEUE_ITEM_DELETED",
description: [
"Controls whether to notify when a queue item is deleted."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "NOTIFIARR__ON_DOWNLOAD_CLEANED",
description: [
"Controls whether to notify when a download is cleaned."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
},
{
name: "NOTIFIARR__ON_CATEGORY_CHANGED",
description: [
"Controls whether to notify when a download's category is changed."
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
}
];
export default function NotifiarrSettings() {
return <EnvVars vars={settings} />;
}

View File

@@ -208,10 +208,11 @@ function IntegrationsSection() {
{ name: "Radarr", icon: "🎬", color: "#ffc107" },
{ name: "Lidarr", icon: "🎵", color: "#28a745" },
{ name: "Readarr", icon: "📚", color: "#6f42c1" },
//{ name: "Whisparr", icon: "🔞", color: "#dc3545" },
{ name: "Whisparr", icon: "🔞", color: "#dc3545" },
{ name: "qBittorrent", icon: "⬇️", color: "#17a2b8" },
{ name: "Deluge", icon: "🌊", color: "#fd7e14" },
{ name: "Transmission", icon: "📡", color: "#e83e8c" }
{ name: "Transmission", icon: "📡", color: "#e83e8c" },
{ name: "µTorrent", icon: "🌀", color: "#343a40" },
];
return (

7
whitelist_with_subtitles Normal file
View File

@@ -0,0 +1,7 @@
*.avi
*.mp4
*.mkv
*.ass
*.srt
*.ssa
*.sub