Compare commits

..

21 commits

Author SHA1 Message Date
Roger Far
858bd7c95c no message
Some checks failed
dotnet test / build (push) Has been cancelled
Create GitHub Release / Test, Build, and Bundle (push) Has been cancelled
Release Docker Image / build (map[arch:amd64 platform:linux/amd64 runs-on:ubuntu-latest]) (push) Has been cancelled
Release Docker Image / build (map[arch:arm64 platform:linux/arm64 runs-on:ubuntu-24.04-arm]) (push) Has been cancelled
Create GitHub Release / Create GitHub release (push) Has been cancelled
Release Docker Image / push-images (push) Has been cancelled
2026-06-05 20:32:20 -06:00
Roger Far
2822653d3b Merge branch 'main' of https://github.com/rogerfar/rdt-client 2026-06-05 20:16:42 -06:00
Roger Far
e8a86cf806 Fix torbox save path mapping for single file downloads.
Some checks failed
Release Docker Image / build (map[arch:amd64 platform:linux/amd64 runs-on:ubuntu-latest]) (push) Has been cancelled
Release Docker Image / build (map[arch:arm64 platform:linux/arm64 runs-on:ubuntu-24.04-arm]) (push) Has been cancelled
Create GitHub Release / Test, Build, and Bundle (push) Has been cancelled
dotnet test / build (push) Has been cancelled
Release Docker Image / push-images (push) Has been cancelled
Create GitHub Release / Create GitHub release (push) Has been cancelled
2026-06-05 20:16:14 -06:00
Roger Far
38530cd6fd
Merge pull request #991 from omgbeez/work/sabnzdb-delete
fix(sabnzbd): Fix delete implementation to respect finished action
2026-05-31 16:06:39 -06:00
omgbeez
a7fb1e3e9d
fix(sabnzbd): Fix delete implementation to respect finished action 2026-05-31 17:12:13 -04:00
Roger Far
5c8a078927 no message
Some checks failed
Release Docker Image / build (map[arch:amd64 platform:linux/amd64 runs-on:ubuntu-latest]) (push) Has been cancelled
Release Docker Image / build (map[arch:arm64 platform:linux/arm64 runs-on:ubuntu-24.04-arm]) (push) Has been cancelled
Create GitHub Release / Test, Build, and Bundle (push) Has been cancelled
dotnet test / build (push) Has been cancelled
Release Docker Image / push-images (push) Has been cancelled
Create GitHub Release / Create GitHub release (push) Has been cancelled
2026-05-30 11:52:11 -06:00
Roger Far
f369e97d6e
Merge pull request #989 from omgbeez/upstream/devcontainer
Add dev container setup for streamlined development
2026-05-30 08:57:55 -06:00
Roger Far
a9013e0c2a
Merge pull request #986 from asylumexp/fix/torbox-database-error
[TorBox] fix: remove usage of GetHashInfoAsync
2026-05-30 08:55:56 -06:00
Sam Heinz
c6f6008591 Migrate existing TorBox torrents RdId 2026-05-29 23:04:22 +10:00
omgbeez
b8e467652b
add(devcontainer): Add dev container setup for streamlined development
Includes Dockerfile, scripts for post-start and post-create configuration, and README instructions for using the environment with .NET and Angular.
2026-05-28 15:44:43 -04:00
Sam Heinz
e1635a43a9 fix: remove usage of GetHashInfoAsync
Removes GetHashInfoAsync, and only uses the actual torrentId.

This was originally required as cachedtorrents endpoints used to use a different torrentId to the regular torrent endpoints for whatever reason, but luckily they figured out a while ago that that was not a good idea and just combined them which means calling API to search for hashes isnt needed anymore, hopefully this will reduce rate limiting a bit.
2026-05-28 14:50:39 +10:00
Roger Far
f5cee6e2f3 no message
Some checks failed
Release Docker Image / build (map[arch:amd64 platform:linux/amd64 runs-on:ubuntu-latest]) (push) Has been cancelled
Release Docker Image / build (map[arch:arm64 platform:linux/arm64 runs-on:ubuntu-24.04-arm]) (push) Has been cancelled
Create GitHub Release / Test, Build, and Bundle (push) Has been cancelled
dotnet test / build (push) Has been cancelled
Release Docker Image / push-images (push) Has been cancelled
Create GitHub Release / Create GitHub release (push) Has been cancelled
2026-05-27 22:08:01 -06:00
Roger Far
767038528a Cleanup. 2026-05-27 22:04:30 -06:00
Roger Far
a82e895a03 no message 2026-05-27 22:03:59 -06:00
Roger Far
68edd4d80d Merge branch 'main' of https://github.com/rogerfar/rdt-client
# Conflicts:
#	server/RdtClient.Service.Test/GlobalTestConfig.cs
#	server/RdtClient.Web.Test/Controllers/QBittorrentControllerTest.cs
#	server/RdtClient.Web.Test/GlobalTestConfig.cs
#	server/RdtClient.Web/Controllers/QBittorrentController.cs
2026-05-27 21:57:27 -06:00
Roger Far
d6f747c8d8 Add quick fix to reject torrents that error out as soon as they are added. 2026-05-27 21:50:21 -06:00
Roger Far
4eb34cbef9
Merge pull request #983 from asylumexp/fix/torbox-database-error
[TorBox] Handle rate limits/database errors for unrestricted links with download delays
2026-05-27 21:49:16 -06:00
Roger Far
5b7b526390
Merge pull request #984 from ALenfant/antonin/sabnzbd-api-key
Support SABnzbd API key auth
2026-05-27 21:48:38 -06:00
Antonin Lenfant-Kodia
49ab603708 Support SABnzbd API key auth 2026-05-26 21:45:48 +02:00
Sam Heinz
1166e9ba99 bump torbox.net to 2.0 2026-05-26 20:16:53 +10:00
Sam Heinz
a86056a3ef handle rate limiting and database errors by delaying downloads 2026-05-26 20:16:07 +10:00
42 changed files with 798 additions and 208 deletions

14
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM mcr.microsoft.com/devcontainers/dotnet:2-10.0-noble
ARG NODE_MAJOR=22
ARG ANGULAR_CLI_VERSION=21
RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs sqlite3 \
&& npm install -g "@angular/cli@^${ANGULAR_CLI_VERSION}" \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /data/db /data/downloads \
&& chown -R vscode:vscode /data

View file

@ -0,0 +1,51 @@
{
"name": "rdt-client",
"build": {
"dockerfile": "Dockerfile",
"args": {
"NODE_MAJOR": "22"
}
},
"remoteUser": "vscode",
"runArgs": ["--init"],
"containerEnv": {
"ASPNETCORE_ENVIRONMENT": "Development",
"BASE_PATH": "",
"DOTNET_USE_POLLING_FILE_WATCHER": "1",
"CHOKIDAR_USEPOLLING": "true"
},
"forwardPorts": [4200, 6500],
"portsAttributes": {
"4200": {
"label": "Angular client",
"onAutoForward": "notify"
},
"6500": {
"label": "RdtClient backend",
"onAutoForward": "silent"
}
},
"mounts": [
"source=rdtclient-data-db,target=/data/db,type=volume",
"source=rdtclient-data-downloads,target=/data/downloads,type=volume",
"source=rdtclient-nuget,target=/home/vscode/.nuget,type=volume",
"source=rdtclient-npm-cache,target=/home/vscode/.npm,type=volume"
],
"postCreateCommand": "/bin/bash .devcontainer/post-create.sh",
"postStartCommand": "/bin/bash .devcontainer/post-start.sh",
"customizations": {
"vscode": {
"extensions": [
"ms-dotnettools.csharp",
"ms-dotnettools.csdevkit",
"Angular.ng-template",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"dotnet.defaultSolution": "server/RdtClient.sln",
"terminal.integrated.defaultProfile.linux": "zsh"
}
}
}
}

16
.devcontainer/post-create.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
sudo install -d -m 0775 -o vscode -g vscode \
/data/db \
/data/downloads \
/home/vscode/.nuget \
/home/vscode/.nuget/NuGet \
/home/vscode/.nuget/packages \
/home/vscode/.npm
sudo chown -R vscode:vscode /data /home/vscode/.nuget /home/vscode/.npm
dotnet restore server/RdtClient.sln
npm install --prefix client

13
.devcontainer/post-start.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
sudo install -d -m 0775 -o vscode -g vscode \
/data/db \
/data/downloads \
/home/vscode/.nuget \
/home/vscode/.nuget/NuGet \
/home/vscode/.nuget/packages \
/home/vscode/.npm
sudo chown -R vscode:vscode /data /home/vscode/.nuget /home/vscode/.npm

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ server/RdtClient.Web/appsettings.Development.json
data
Dockerfile.dev
test.bat
.DS_Store

View file

@ -6,9 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased
## [2.0.138] - 2026-06-05
### Fixed
- SABnzbd fixes.
## [2.0.137] - 2026-06-05
### Fixed
- Fix torbox save path mapping for single file downloads.
## [2.0.136] - 2026-05-30
### Added
- Added devcontainer development workflow.
### Changed
- Upgraded torbox.net dependency.
## [2.0.135] - 2026-05-27
### Changed
- When adding a torrent through the qBittorrent endpoints, it will wait to see if the torrent gets added properly or errors out, resulting in a better experience for infringing files and Sonarr / Radarr.
- Upgraded torbox.net dependency.
- Support SABnzbd API key auth, thanks to @ALenfant!
## [2.0.134] - 2026-05-22
### Changed
Upgraded torbox.net dependency.
- Upgraded torbox.net dependency.
## [2.0.133] - 2026-05-17
### Added

View file

@ -196,7 +196,7 @@ RdtClient also emulates part of the SABnzbd API so Sonarr and Radarr can add NZB
1. Enter `6500` in the `Port` field.
1. Enable `Use SSL` only if you access RdtClient through HTTPS.
1. Leave `URL Base` empty unless RdtClient is configured with a `BasePath`, for example `/rdt`.
1. If RdtClient authentication is enabled, leave `API Key` empty and enter your RdtClient username and password.
1. If RdtClient authentication is enabled, leave `API Key` empty and enter your RdtClient username and password. If your client only supports an API key, enter `{username}:{password}` in `API Key`.
1. If RdtClient authentication is disabled, enter any value in `API Key`, for example `rdtclient`, and leave username/password empty.
1. Set the category to `sonarr` for Sonarr or `radarr` for Radarr.
1. Hit `Test` and then `Save` if all is well.
@ -218,6 +218,17 @@ By default the application runs in the root of your hosted address (i.e. https:/
- Visual Studio 2025
- (optional) Resharper
### Dev Container
The repository includes a dev container under `.devcontainer/` for the split development workflow used by this project.
It installs .NET 10 and Node 22, forwards ports `4200` and `6500`, and persists `/data/db` and `/data/downloads` in named volumes so the local SQLite database, logs, and downloads survive container rebuilds.
1. Open the repository in the dev container.
1. In one terminal run `dotnet watch run --project server/RdtClient.Web`.
1. In another terminal run `cd client && npm start`.
1. Open `http://localhost:4200`. The Angular dev server proxies `/Api` and `/hub` to the backend running on `6500`.
1. Open the client folder project in VS Code and run `npm install`.
1. To debug run `ng serve`, to build run `ng build -c production`.
1. Open the Visual Studio 2025 project `RdtClient.sln` and `Publish` the `RdtClient.Web` to the given `PublishFolder` target.

View file

@ -74,6 +74,7 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
return DownloadAddResult.Added;
}
// These shouldn't be possible any longer, but added for safety and until confirmed.
catch (DbUpdateException ex)
{
@ -88,7 +89,9 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
if (IsForeignKeyViolation(ex) && !await dataContext.Torrents.AsNoTracking().AnyAsync(m => m.TorrentId == torrentId))
{
logger?.LogDebug("Skipped download creation after the torrent was deleted concurrently. TorrentId: {torrentId}, Path: {path}", torrentId, downloadInfo.RestrictedLink);
logger?.LogDebug("Skipped download creation after the torrent was deleted concurrently. TorrentId: {torrentId}, Path: {path}",
torrentId,
downloadInfo.RestrictedLink);
return DownloadAddResult.TorrentMissing;
}
@ -273,7 +276,7 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
await dataContext.SaveChangesAsync();
}
public async Task Reset(Guid downloadId)
public async Task Reset(Guid downloadId, DateTimeOffset? downloadQueued = null)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId)
@ -282,7 +285,7 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.RetryCount = 0;
dbDownload.Link = null;
dbDownload.Added = DateTimeOffset.UtcNow;
dbDownload.DownloadQueued = DateTimeOffset.UtcNow;
dbDownload.DownloadQueued = downloadQueued ?? DateTimeOffset.UtcNow;
dbDownload.DownloadStarted = null;
dbDownload.DownloadFinished = null;
dbDownload.UnpackingQueued = null;

View file

@ -10,11 +10,11 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
public async Task<IList<Torrent>> Get()
{
var torrents = await dataContext.Torrents
.AsNoTracking()
.AsSplitQuery()
.Include(m => m.Downloads)
.OrderBy(m => m.Priority ?? 9999)
.ToListAsync();
.AsNoTracking()
.AsSplitQuery()
.Include(m => m.Downloads)
.OrderBy(m => m.Priority ?? 9999)
.ToListAsync();
return torrents.OrderBy(m => m.Priority ?? 9999)
.ThenBy(m => m.Added)

View file

@ -63,6 +63,7 @@ public class Torrent
public DateTimeOffset? RdEnded { get; set; }
public Int64? RdSpeed { get; set; }
public Int64? RdSeeders { get; set; }
public String? RdFiles
{
get => _rdFiles;

View file

@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Services;
@ -25,7 +24,8 @@ public class WatchFolderCheckerTests : IDisposable
_serviceProviderMock = new();
_serviceScopeMock = new();
_scopeServiceProviderMock = new();
_settings = new(new DbSettings
_settings = new(new()
{
Watch = new()
{
@ -34,6 +34,7 @@ public class WatchFolderCheckerTests : IDisposable
},
DownloadClient = new()
});
_torrentsServiceMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_serviceProviderMock

View file

@ -0,0 +1 @@
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View file

@ -12,17 +12,17 @@
<ItemGroup>
<PackageReference Include="AllDebrid.NET" Version="1.0.18" />
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="SharpCompress" Version="[0.42.1]" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.1" />
<PackageReference Include="TorBox.NET" Version="1.8.1" />
<PackageReference Include="TorBox.NET" Version="2.1.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
@ -38,10 +38,4 @@
<ProjectReference Include="..\RdtClient.Service\RdtClient.Service.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="TorBoxNET">
<HintPath>..\..\..\torbox-net\TorBoxNET\bin\Release\netstandard2.0\TorBoxNET.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -162,6 +162,7 @@ public class TorrentDownloadRaceTests : IAsyncLifetime
var torrentId = Guid.NewGuid();
await using var context = CreateContext();
context.Torrents.Add(new()
{
TorrentId = torrentId,

View file

@ -3,7 +3,6 @@ using System.Text;
using Moq;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using TestSettings = RdtClient.Service.Services.TestSettings;
using TorrentRunnerState = RdtClient.Service.Services.TorrentRunnerState;
using TorrentsService = RdtClient.Service.Services.Torrents;

View file

@ -177,19 +177,20 @@ public class QBittorrentTest
// Assert
Assert.NotNull(result);
Assert.Collection(result!,
first =>
{
Assert.Equal(0, first.Index);
Assert.Equal("good.mkv", first.Name);
Assert.Equal(1, first.Priority);
},
second =>
{
Assert.Equal(1, second.Index);
Assert.Equal("dangerous.exe", second.Name);
Assert.Equal(0, second.Priority);
});
first =>
{
Assert.Equal(0, first.Index);
Assert.Equal("good.mkv", first.Name);
Assert.Equal(1, first.Priority);
},
second =>
{
Assert.Equal(1, second.Index);
Assert.Equal("dangerous.exe", second.Name);
Assert.Equal(0, second.Priority);
});
}
[Fact]
@ -251,7 +252,11 @@ public class QBittorrentTest
Type = DownloadType.Torrent
};
_torrentsMock.Setup(m => m.Get()).ReturnsAsync(new List<Torrent> { torrent });
_torrentsMock.Setup(m => m.Get())
.ReturnsAsync(new List<Torrent>
{
torrent
});
// Act
var result = await _qBittorrent.TorrentInfo();
@ -311,7 +316,11 @@ public class QBittorrentTest
Type = DownloadType.Torrent
};
_torrentsMock.Setup(m => m.Get()).ReturnsAsync(new List<Torrent> { torrent });
_torrentsMock.Setup(m => m.Get())
.ReturnsAsync(new List<Torrent>
{
torrent
});
var result = await _qBittorrent.TorrentInfo();
@ -376,7 +385,11 @@ public class QBittorrentTest
Type = DownloadType.Torrent
};
_torrentsMock.Setup(m => m.Get()).ReturnsAsync(new List<Torrent> { torrent });
_torrentsMock.Setup(m => m.Get())
.ReturnsAsync(new List<Torrent>
{
torrent
});
var result = await _qBittorrent.TorrentInfo();

View file

@ -366,6 +366,64 @@ public class SabnzbdTest
Assert.Equal("Failed", result.Slots[0].Status);
}
[Theory]
[InlineData(TorrentFinishedAction.RemoveAllTorrents, true, true, true, true)]
[InlineData(TorrentFinishedAction.RemoveAllTorrents, false, true, true, false)]
[InlineData(TorrentFinishedAction.RemoveRealDebrid, true, false, true, true)]
[InlineData(TorrentFinishedAction.RemoveRealDebrid, false, false, true, false)]
[InlineData(TorrentFinishedAction.RemoveClient, true, true, false, true)]
[InlineData(TorrentFinishedAction.RemoveClient, false, true, false, false)]
public async Task Delete_ShouldRespectFinishedActionAndDeleteFiles(
TorrentFinishedAction finishedAction,
Boolean deleteFiles,
Boolean expectedDeleteData,
Boolean expectedDeleteRdTorrent,
Boolean expectedDeleteLocalFiles)
{
// Arrange
var torrentId = Guid.NewGuid();
_settings.Current.Integrations.Default.FinishedAction = finishedAction;
_torrentsMock.Setup(t => t.GetByHash("hash1"))
.ReturnsAsync(new Torrent
{
TorrentId = torrentId,
Hash = "hash1",
Type = DownloadType.Nzb
});
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
await sabnzbd.Delete("hash1", deleteFiles);
// Assert
_torrentsMock.Verify(t => t.Delete(torrentId, expectedDeleteData, expectedDeleteRdTorrent, expectedDeleteLocalFiles), Times.Once);
}
[Fact]
public async Task Delete_ShouldNotDelete_WhenFinishedActionIsNone()
{
// Arrange
_settings.Current.Integrations.Default.FinishedAction = TorrentFinishedAction.None;
_torrentsMock.Setup(t => t.GetByHash("hash1"))
.ReturnsAsync(new Torrent
{
TorrentId = Guid.NewGuid(),
Hash = "hash1",
Type = DownloadType.Nzb
});
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
await sabnzbd.Delete("hash1", true);
// Assert
_torrentsMock.Verify(t => t.Delete(It.IsAny<Guid>(), It.IsAny<Boolean>(), It.IsAny<Boolean>(), It.IsAny<Boolean>()), Times.Never);
}
[Fact]
public void GetConfig_ShouldReturnCorrectConfig()
{

View file

@ -46,6 +46,7 @@ public class TorBoxDebridClientTest
{
new()
{
Id = 12345,
Hash = "hash1",
Name = "torrent1",
Size = 1000,
@ -78,7 +79,7 @@ public class TorBoxDebridClientTest
// Assert
Assert.Equal(2, result.Count);
var torrentResult = result.FirstOrDefault(r => r.Id == "hash1");
var torrentResult = result.FirstOrDefault(r => r.Id == "12345");
Assert.NotNull(torrentResult);
Assert.Equal(DownloadType.Torrent, torrentResult.Type);
@ -200,12 +201,12 @@ public class TorBoxDebridClientTest
}
[Fact]
public async Task Delete_CallsTorrentsControl_WhenTypeIsTorrent()
public async Task Delete_CallsTorrentsControlById_WhenTypeIsTorrent()
{
// Arrange
var torrent = new Torrent
{
RdId = "torrent-id",
RdId = "12345",
Type = DownloadType.Torrent
};
@ -220,7 +221,7 @@ public class TorBoxDebridClientTest
await clientMock.Object.Delete(torrent);
// Assert
torrentsApiMock.Verify(m => m.ControlAsync("torrent-id", "delete", It.IsAny<CancellationToken>()), Times.Once);
torrentsApiMock.Verify(m => m.ControlByIdAsync(12345, "delete", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@ -375,6 +376,7 @@ public class TorBoxDebridClientTest
var torrent = new Torrent
{
Hash = "test-hash",
RdId = "12345",
RdFiles = JsonConvert.SerializeObject(files)
};
@ -385,12 +387,6 @@ public class TorBoxDebridClientTest
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, 1000, It.IsAny<CancellationToken>()))
.ReturnsAsync(new TorrentInfoResult
{
Id = 12345
});
_fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).Returns(true);
_settings.Current.Provider.PreferZippedDownloads = false;
@ -422,6 +418,7 @@ public class TorBoxDebridClientTest
var torrent = new Torrent
{
Hash = "test-hash",
RdId = "12345",
RdName = "TestTorrent",
RdFiles = JsonConvert.SerializeObject(files),
DownloadClient = DownloadClient.Aria2c
@ -436,12 +433,6 @@ public class TorBoxDebridClientTest
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, 1000, It.IsAny<CancellationToken>()))
.ReturnsAsync(new TorrentInfoResult
{
Id = 12345
});
_fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).Returns(true);
// Act

View file

@ -36,10 +36,12 @@ public class TorrentRunnerTest
};
var torrentDataMock = new Mock<ITorrentData>(MockBehavior.Strict);
torrentDataMock.Setup(m => m.Get()).ReturnsAsync(new List<Torrent>
{
erroredTorrent
});
torrentDataMock.Setup(m => m.Get())
.ReturnsAsync(new List<Torrent>
{
erroredTorrent
});
var torrents = new Torrents(Mock.Of<ILogger<Torrents>>(),
torrentDataMock.Object,

View file

@ -1,6 +1,7 @@
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services;
namespace RdtClient.Service.Services;
namespace RdtClient.Service.Test;
internal sealed class TestSettings(DbSettings? settings = null) : ISettings
{

View file

@ -30,7 +30,7 @@ public static class DiConfig
services.AddScoped<AllDebridDebridClient>();
services.AddSingleton<IRateLimitCoordinator, RateLimitCoordinator>();
services.AddSingleton<ITorrentRunnerState>(TorrentRunner.SharedState);
services.AddSingleton(TorrentRunner.SharedState);
services.AddSingleton<IProcessFactory, ProcessFactory>();
services.AddSingleton<IFileSystem, FileSystem>();

View file

@ -65,6 +65,36 @@ public class SabnzbdHandler(Authentication authentication, IHttpContextAccessor
return;
}
var apiKey = GetParam("apikey");
if (!String.IsNullOrWhiteSpace(apiKey))
{
var separatorIndex = apiKey.IndexOf(':');
if (separatorIndex <= 0 || separatorIndex == apiKey.Length - 1)
{
context.Fail();
return;
}
var username = apiKey[..separatorIndex];
var password = apiKey[(separatorIndex + 1)..];
var loginResult = await authentication.Login(username, password);
if (loginResult.Succeeded)
{
context.Succeed(requirement);
return;
}
context.Fail();
return;
}
// Authentication required but missing credentials
context.Fail();
}

View file

@ -26,7 +26,7 @@
<PackageReference Include="Synology.Api.Client" Version="[0.3.93]" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.1" />
<PackageReference Include="TorBox.NET" Version="1.8.1" />
<PackageReference Include="TorBox.NET" Version="2.1.0" />
</ItemGroup>
<ItemGroup>

View file

@ -12,7 +12,8 @@ using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services.DebridClients;
public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, ISettings settings) : IDebridClient
public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, ISettings settings)
: IDebridClient
{
private const String TransferCreateUrl = "https://www.premiumize.me/api/transfer/create";
@ -331,11 +332,11 @@ public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHtt
private static String FormatPremiumizeError(RawTransferCreateResponse result)
{
return String.Join(": ", new[]
{
result.Code,
result.Message
}.Where(m => !String.IsNullOrWhiteSpace(m)));
return String.Join(": ",
new[]
{
result.Code, result.Message
}.Where(m => !String.IsNullOrWhiteSpace(m)));
}
private static Boolean IsRateLimitMessage(String message)

View file

@ -12,7 +12,8 @@ using Torrent = RDNET.Torrent;
namespace RdtClient.Service.Services.DebridClients;
public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, ISettings settings) : IDebridClient
public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, ISettings settings)
: IDebridClient
{
private TimeSpan? _offset;

View file

@ -11,7 +11,12 @@ using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services.DebridClients;
public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, IRateLimitCoordinator coordinator, ISettings settings)
public class TorBoxDebridClient(
ILogger<TorBoxDebridClient> logger,
IHttpClientFactory httpClientFactory,
IDownloadableFileFilter fileFilter,
IRateLimitCoordinator coordinator,
ISettings settings)
: IDebridClient
{
private const String TorBoxApiHost = "api.torbox.app";
@ -69,9 +74,14 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
return await HandleAddTorrentErrors(async asQueued =>
{
var user = await GetClient().User.GetAsync(true);
var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued);
return result.Data!.Hash!;
var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW)
.Torrents.AddMagnetAsync(magnetLink,
user.Data?.Settings?.SeedTorrents ?? 3,
allowZip: Settings.Get.Provider.PreferZippedDownloads,
as_queued: asQueued);
return result.Data?.TorrentId?.ToString() ?? throw new InvalidOperationException("TorBox API did not return torrent ID.");
});
}
@ -80,9 +90,14 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
return await HandleAddTorrentErrors(async asQueued =>
{
var user = await GetClient().User.GetAsync(true);
var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued);
return result.Data!.Hash!;
var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW)
.Torrents.AddFileAsync(bytes,
user.Data?.Settings?.SeedTorrents ?? 3,
allowZip: Settings.Get.Provider.PreferZippedDownloads,
as_queued: asQueued);
return result.Data?.TorrentId?.ToString() ?? throw new InvalidOperationException("TorBox API did not return torrent ID");
});
}
@ -156,7 +171,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
}
else
{
await GetClient().Torrents.ControlAsync(torrent.RdId, "delete");
await GetClient().Torrents.ControlByIdAsync(Int32.Parse(torrent.RdId), "delete");
}
});
}
@ -316,8 +331,12 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
}
else
{
var torrentId = await HandleErrors(() => GetClient().Torrents.GetHashInfoAsync(torrent.Hash, true));
id = torrentId?.Id;
if (torrent.RdId == null)
{
return null;
}
id = Int32.Parse(torrent.RdId);
}
if (id == null)
@ -372,7 +391,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
}
var httpClient = httpClientFactory.CreateClient(clientId);
var torBoxNetClient = new TorBoxNetClient(null, httpClient);
var torBoxNetClient = new TorBoxNetClient(null, httpClient, retryCount: 5);
torBoxNetClient.UseApiAuthentication(apiKey);
// Get the server time to fix up the timezones on results
@ -441,7 +460,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{
return new()
{
Id = torrent.Hash,
Id = torrent.Id.ToString(),
Filename = torrent.Name,
OriginalFilename = torrent.Name,
Hash = torrent.Hash,
@ -513,7 +532,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{
throw rateLimitException;
}
catch (TorBoxException ex) when ("active_limit".Equals(ex.Error, StringComparison.OrdinalIgnoreCase))
catch (TorBoxException ex) when (IsRateLimit(ex))
{
coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2));
@ -541,7 +560,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{
throw rateLimitException;
}
catch (TorBoxException ex) when ("active_limit".Equals(ex.Error, StringComparison.OrdinalIgnoreCase))
catch (TorBoxException ex) when (IsRateLimit(ex))
{
coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2));
@ -560,6 +579,12 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
return await HandleErrors(() => action(false));
}
private static Boolean IsRateLimit(TorBoxException exception)
{
return exception.Error.Equals("RATE_LIMIT", StringComparison.OrdinalIgnoreCase)
|| exception.Error.Equals("ACTIVE_LIMIT", StringComparison.OrdinalIgnoreCase);
}
private async Task<String> HandleAddUsenetErrors(Func<Boolean, Task<String>> action)
{
return await HandleErrors(() => action(false));
@ -590,7 +615,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
}
else
{
var result = await GetClient().Torrents.GetHashInfoAsync(id, true);
var result = await GetClient().Torrents.GetIdInfoAsync(Int32.Parse(id), true);
if (result != null)
{

View file

@ -86,8 +86,8 @@ public class Downloads(DownloadData downloadData) : IDownloads
await downloadData.DeleteForTorrent(torrentId);
}
public async Task Reset(Guid downloadId)
public async Task Reset(Guid downloadId, DateTimeOffset? downloadQueued = null)
{
await downloadData.Reset(downloadId);
await downloadData.Reset(downloadId, downloadQueued);
}
}

View file

@ -21,5 +21,5 @@ public interface IDownloads
Task UpdateRetryCount(Guid downloadId, Int32 retryCount);
Task UpdateRemoteId(Guid downloadId, String remoteId);
Task DeleteForTorrent(Guid torrentId);
Task Reset(Guid downloadId);
Task Reset(Guid downloadId, DateTimeOffset? downloadQueued = null);
}

View file

@ -346,19 +346,24 @@ public class QBittorrent(ILogger<QBittorrent> logger, ISettings settings, Authen
}
var topLevelSelectedFiles = torrent.Files
.Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path))
.Select(m => m.Path.Trim('/').Trim('\\'))
.Where(m => m.IndexOfAny(['/', '\\']) < 0)
.Select(Path.GetFileName)
.Where(m => !String.IsNullOrWhiteSpace(m))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
.Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path))
.Select(m => m.Path.Trim('/').Trim('\\'))
.Where(m => m.IndexOfAny(['/', '\\']) < 0)
.Select(Path.GetFileName)
.Where(m => !String.IsNullOrWhiteSpace(m))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (topLevelSelectedFiles.Count == 1)
{
var selectedFileName = topLevelSelectedFiles[0]!;
var selectedFileBaseName = Path.GetFileNameWithoutExtension(selectedFileName);
if (torrent.ClientKind == Provider.TorBox)
{
return selectedFileBaseName;
}
if (!String.IsNullOrWhiteSpace(selectedFileBaseName) &&
selectedFileBaseName.Equals(torrent.RdName, StringComparison.OrdinalIgnoreCase))
{
@ -501,16 +506,16 @@ public class QBittorrent(ILogger<QBittorrent> logger, ISettings settings, Authen
var progress = torrent.Completed.HasValue || torrent.RdStatus == TorrentStatus.Finished ? 1f : 0f;
return torrent.Files
.Select((file, index) => new TorrentFileItem
{
Index = index,
Name = file.Path,
Size = file.Bytes,
Progress = file.Selected ? progress : 0f,
Priority = file.Selected ? 1 : 0,
IsSeed = false
})
.ToList();
.Select((file, index) => new TorrentFileItem
{
Index = index,
Name = file.Path,
Size = file.Bytes,
Progress = file.Selected ? progress : 0f,
Priority = file.Selected ? 1 : 0,
IsSeed = false
})
.ToList();
}
public async Task<TorrentProperties?> TorrentProperties(String hash)
@ -633,7 +638,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, ISettings settings, Authen
}
}
public async Task TorrentsAddMagnet(String magnetLink, String? category, Int32? priority)
public async Task<Torrent> TorrentsAddMagnet(String magnetLink, String? category, Int32? priority)
{
logger.LogDebug($"Add magnet {category}");
@ -655,10 +660,10 @@ public class QBittorrent(ILogger<QBittorrent> logger, ISettings settings, Authen
Priority = priority ?? (settings.Current.Integrations.Default.Priority > 0 ? settings.Current.Integrations.Default.Priority : null)
};
await torrents.AddMagnetToDebridQueue(magnetLink, torrent);
return await torrents.AddMagnetToDebridQueue(magnetLink, torrent);
}
public async Task TorrentsAddFile(Byte[] fileBytes, String? category, Int32? priority)
public async Task<Torrent> TorrentsAddFile(Byte[] fileBytes, String? category, Int32? priority)
{
logger.LogDebug($"Add file {category}");
@ -680,7 +685,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, ISettings settings, Authen
Priority = priority ?? (settings.Current.Integrations.Default.Priority > 0 ? settings.Current.Integrations.Default.Priority : null)
};
await torrents.AddFileToDebridQueue(fileBytes, torrent);
return await torrents.AddFileToDebridQueue(fileBytes, torrent);
}
public async Task TorrentsSetCategory(String hash, String? category)

View file

@ -177,7 +177,7 @@ public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings app
return result.Hash;
}
public virtual async Task Delete(String hash)
public virtual async Task Delete(String hash, Boolean deleteFiles = false)
{
var torrent = await torrents.GetByHash(hash);
@ -189,18 +189,18 @@ public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings app
switch (settings.Current.Integrations.Default.FinishedAction)
{
case TorrentFinishedAction.RemoveAllTorrents:
logger.LogDebug("Removing nzb from debrid provider and RDT-Client, no files");
await torrents.Delete(torrent.TorrentId, true, true, true);
logger.LogDebug("Removing nzb from debrid provider and RDT-Client, {Files}", deleteFiles ? "with files" : "no files");
await torrents.Delete(torrent.TorrentId, true, true, deleteFiles);
break;
case TorrentFinishedAction.RemoveRealDebrid:
logger.LogDebug("Removing nzb from debrid provider, no files");
await torrents.Delete(torrent.TorrentId, false, true, true);
logger.LogDebug("Removing nzb from debrid provider, {Files}", deleteFiles ? "with files" : "no files");
await torrents.Delete(torrent.TorrentId, false, true, deleteFiles);
break;
case TorrentFinishedAction.RemoveClient:
logger.LogDebug("Removing nzb from client, no files");
await torrents.Delete(torrent.TorrentId, true, false, true);
logger.LogDebug("Removing nzb from client, {Files}", deleteFiles ? "with files" : "no files");
await torrents.Delete(torrent.TorrentId, true, false, deleteFiles);
break;
case TorrentFinishedAction.None:

View file

@ -41,6 +41,11 @@ public class Settings(IServiceScopeFactory serviceScopeFactory) : ISettings
{
var downloadPath = settings.DownloadClient.MappedPath;
if (String.IsNullOrWhiteSpace(downloadPath))
{
downloadPath = settings.DownloadClient.DownloadPath;
}
downloadPath = downloadPath.TrimEnd('\\')
.TrimEnd('/');

View file

@ -367,8 +367,10 @@ public class TorrentRunner(
}
// Process torrents in DebridQueue
var torrentsToAddToProvider = allTorrents.Where(m => m.Completed == null && m.Error == null && m.RdId == null && m.RdAdded == null && m.FileOrMagnet != null && m.RdStatus == TorrentStatus.Queued)
.ToList();
var torrentsToAddToProvider = allTorrents
.Where(m => m.Completed == null && m.Error == null && m.RdId == null && m.RdAdded == null && m.FileOrMagnet != null &&
m.RdStatus == TorrentStatus.Queued)
.ToList();
if (torrentsToAddToProvider.Count != 0)
{
@ -477,7 +479,11 @@ public class TorrentRunner(
{
// Check if there are any downloads that are queued and can be started.
var queuedDownloads = torrent.Downloads
.Where(m => m.Completed == null && m.DownloadQueued != null && m.DownloadStarted == null && m.Error == null)
.Where(m => m.Completed == null
&& m.DownloadQueued != null
&& m.DownloadQueued <= DateTimeOffset.UtcNow
&& m.DownloadStarted == null
&& m.Error == null)
.OrderBy(m => m.DownloadQueued)
.ToList();
@ -528,10 +534,24 @@ public class TorrentRunner(
{
logger.LogError(ex, "Cannot unrestrict link: {ex.Message}", ex.Message);
await downloads.UpdateError(download.DownloadId, ex.Message);
await downloads.UpdateCompleted(download.DownloadId, DateTimeOffset.UtcNow);
download.Error = ex.Message;
download.Completed = DateTimeOffset.UtcNow;
if (download.RetryCount < torrent.DownloadRetryAttempts)
{
var retryCount = download.RetryCount + 1;
var retryDelay = GetDownloadLinkRetryDelay(retryCount);
var retryAt = DateTimeOffset.UtcNow.Add(retryDelay);
Log($"Retrying download link generation {retryCount}/{torrent.DownloadRetryAttempts} at {retryAt:u}", download, torrent);
await downloads.Reset(download.DownloadId, retryAt);
await downloads.UpdateRetryCount(download.DownloadId, retryCount);
}
else
{
await downloads.UpdateError(download.DownloadId, ex.Message);
await downloads.UpdateCompleted(download.DownloadId, DateTimeOffset.UtcNow);
download.Error = ex.Message;
download.Completed = DateTimeOffset.UtcNow;
}
return;
}
@ -782,6 +802,19 @@ public class TorrentRunner(
});
}
private static TimeSpan GetDownloadLinkRetryDelay(Int32 retryCount)
{
var seconds = retryCount switch
{
<= 1 => 15,
2 => 30,
3 => 60,
_ => 120
};
return TimeSpan.FromSeconds(seconds);
}
private void Log(String message, Download? download, Torrent? torrent)
{
if (download != null)

View file

@ -664,6 +664,38 @@ public class Torrents(
{
torrentsByRdId.TryGetValue(rdTorrent.Id, out var torrent);
// TorBox migration from storing torrent hash in RdId to torrent ids.
if (torrent == null
&& Settings.Get.Provider.Provider == Provider.TorBox
&& rdTorrent.Type == DownloadType.Torrent
&& !String.IsNullOrWhiteSpace(rdTorrent.Hash)
&& !String.IsNullOrWhiteSpace(rdTorrent.Id))
{
torrent = torrents.FirstOrDefault(localTorrent => localTorrent is { Type: DownloadType.Torrent, ClientKind: null or Provider.TorBox }
&& !String.IsNullOrWhiteSpace(localTorrent.Hash)
&& !String.IsNullOrWhiteSpace(localTorrent.RdId)
&& localTorrent.RdId.Equals(localTorrent.Hash, StringComparison.OrdinalIgnoreCase)
&& localTorrent.Hash.Equals(rdTorrent.Hash, StringComparison.OrdinalIgnoreCase));
if (torrent != null)
{
if (!String.IsNullOrWhiteSpace(torrent.RdId))
{
torrentsByRdId.Remove(torrent.RdId);
}
await torrentData.UpdateRdId(torrent, rdTorrent.Id);
torrent.RdId = rdTorrent.Id;
torrent.ClientKind = Provider.TorBox;
torrentsByRdId[rdTorrent.Id] = torrent;
logger.LogInformation("Migrated TorBox torrent RdId from hash to torrent id for {TorrentName} ({Hash}) -> {RdId}",
torrent.RdName ?? rdTorrent.Filename,
rdTorrent.Hash,
rdTorrent.Id);
}
}
// Auto import torrents only torrents that have their files selected
if (torrent == null && settings.Current.Provider.AutoImport)
{
@ -1137,18 +1169,19 @@ public class Torrents(
torrent.RdFiles);
}
private readonly record struct TorrentRdState(String? RdName,
Int64? RdSize,
String? RdHost,
Int64? RdSplit,
Int64? RdProgress,
TorrentStatus? RdStatus,
String? RdStatusRaw,
DateTimeOffset? RdAdded,
DateTimeOffset? RdEnded,
Int64? RdSpeed,
Int64? RdSeeders,
String? RdFiles);
private readonly record struct TorrentRdState(
String? RdName,
Int64? RdSize,
String? RdHost,
Int64? RdSplit,
Int64? RdProgress,
TorrentStatus? RdStatus,
String? RdStatusRaw,
DateTimeOffset? RdAdded,
DateTimeOffset? RdEnded,
Int64? RdSpeed,
Int64? RdSeeders,
String? RdFiles);
private void Log(String message, Download? download, Torrent? torrent)
{

View file

@ -12,18 +12,20 @@ public class QBittorrentControllerTest
{
private readonly QBittorrentController _controller;
private readonly Mock<QBittorrent> _qBittorrentMock;
private readonly Mock<Torrents> _torrentsMock;
private readonly TestSettings _settings;
public QBittorrentControllerTest()
{
_settings = new();
_qBittorrentMock = new(new Mock<ILogger<QBittorrent>>().Object, _settings, null!, null!, null!, new TorrentRunnerState());
_torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_controller = new(
new Mock<ILogger<QBittorrentController>>().Object,
_qBittorrentMock.Object,
new Mock<IHttpClientFactory>().Object,
_settings);
_controller = new(new Mock<ILogger<QBittorrentController>>().Object,
_qBittorrentMock.Object,
new Mock<IHttpClientFactory>().Object,
_settings,
_torrentsMock.Object);
_controller.ControllerContext = new()
{
@ -35,15 +37,16 @@ public class QBittorrentControllerTest
public async Task TorrentsInfo_FilterAll_DoesNotFilterOutResults()
{
// Arrange
_qBittorrentMock.Setup(q => q.TorrentInfo()).ReturnsAsync(new List<TorrentInfo>
{
new()
{
Hash = "hash1",
State = "pausedUP",
Progress = 1f
}
});
_qBittorrentMock.Setup(q => q.TorrentInfo())
.ReturnsAsync(new List<TorrentInfo>
{
new()
{
Hash = "hash1",
State = "pausedUP",
Progress = 1f
}
});
// Act
var result = await _controller.TorrentsInfo(new()
@ -63,21 +66,22 @@ public class QBittorrentControllerTest
public async Task TorrentsInfo_FilterCompleted_MatchesPausedUploadTorrents()
{
// Arrange
_qBittorrentMock.Setup(q => q.TorrentInfo()).ReturnsAsync(new List<TorrentInfo>
{
new()
{
Hash = "hash1",
State = "pausedUP",
Progress = 1f
},
new()
{
Hash = "hash2",
State = "downloading",
Progress = 0.4f
}
});
_qBittorrentMock.Setup(q => q.TorrentInfo())
.ReturnsAsync(new List<TorrentInfo>
{
new()
{
Hash = "hash1",
State = "pausedUP",
Progress = 1f
},
new()
{
Hash = "hash2",
State = "downloading",
Progress = 0.4f
}
});
// Act
var result = await _controller.TorrentsInfo(new()

View file

@ -108,6 +108,30 @@ public class SabnzbdControllerTest
Assert.IsType<OkObjectResult>(result);
}
[Theory]
[InlineData("?name=delete&value=hash1&del_files=1", true)]
[InlineData("?name=delete&value=hash1&del_files=true", true)]
[InlineData("?name=delete&value=hash1&del_files=0", false)]
[InlineData("?name=delete&value=hash1", false)]
public async Task Queue_Delete_PassesDeleteFilesFlag(String queryString, Boolean expectedDeleteFiles)
{
// Arrange
var httpContext = new DefaultHttpContext();
httpContext.Request.QueryString = new(queryString);
_controller.ControllerContext.HttpContext = httpContext;
_sabnzbdMock.Setup(s => s.Delete("hash1", expectedDeleteFiles)).Returns(Task.CompletedTask);
// Act
var result = await _controller.Queue();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(okResult.Value);
Assert.True(response.Status);
_sabnzbdMock.Verify(s => s.Delete("hash1", expectedDeleteFiles), Times.Once);
}
[Fact]
public async Task GetHistory_ReturnsOk()
{
@ -129,6 +153,26 @@ public class SabnzbdControllerTest
Assert.Equal(1, response.History.NoOfSlots);
}
[Fact]
public async Task History_Delete_PassesDeleteFilesFlag()
{
// Arrange
var httpContext = new DefaultHttpContext();
httpContext.Request.QueryString = new("?name=delete&value=hash1&del_files=1");
_controller.ControllerContext.HttpContext = httpContext;
_sabnzbdMock.Setup(s => s.Delete("hash1", true)).Returns(Task.CompletedTask);
// Act
var result = await _controller.History();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(okResult.Value);
Assert.True(response.Status);
_sabnzbdMock.Verify(s => s.Delete("hash1", true), Times.Once);
}
[Fact]
public void GetVersion_HasAllowAnonymousAttribute()
{

View file

@ -44,6 +44,7 @@ public class SabnzbdHandlerTest
{
// Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
@ -64,6 +65,114 @@ public class SabnzbdHandlerTest
Assert.True(context.HasSucceeded, "HasSucceeded should be true for valid credentials");
}
[Fact]
public async Task HandleAsync_ValidApiKeyCredentials_Succeeds()
{
// Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
{
QueryString = new("?apikey=user:pass")
}
};
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);
var context = CreateContext(httpContext);
_authenticationMock.Setup(a => a.Login("user", "pass")).ReturnsAsync(SignInResult.Success);
// Act
await _handler.HandleAsync(context);
// Assert
Assert.True(context.HasSucceeded, "HasSucceeded should be true for valid api key credentials");
}
[Fact]
public async Task HandleAsync_ValidApiKeyCredentialsFromForm_Succeeds()
{
// Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext();
httpContext.Request.ContentType = "application/x-www-form-urlencoded";
httpContext.Request.Form = new FormCollection(new()
{
{
"apikey", "user:pass"
}
});
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);
var context = CreateContext(httpContext);
_authenticationMock.Setup(a => a.Login("user", "pass")).ReturnsAsync(SignInResult.Success);
// Act
await _handler.HandleAsync(context);
// Assert
Assert.True(context.HasSucceeded, "HasSucceeded should be true for valid form api key credentials");
}
[Fact]
public async Task HandleAsync_ApiKeyPasswordContainingColon_Succeeds()
{
// Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
{
QueryString = new("?apikey=user:pass:with:colons")
}
};
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);
var context = CreateContext(httpContext);
_authenticationMock.Setup(a => a.Login("user", "pass:with:colons")).ReturnsAsync(SignInResult.Success);
// Act
await _handler.HandleAsync(context);
// Assert
Assert.True(context.HasSucceeded, "HasSucceeded should be true when the password contains colons");
}
[Fact]
public async Task HandleAsync_MaCredentialsTakePrecedenceOverApiKey()
{
// Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
{
QueryString = new("?ma_username=user&ma_password=wrong&apikey=user:pass")
}
};
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);
var context = CreateContext(httpContext);
_authenticationMock.Setup(a => a.Login("user", "wrong")).ReturnsAsync(SignInResult.Failed);
_authenticationMock.Setup(a => a.Login("user", "pass")).ReturnsAsync(SignInResult.Success);
// Act
await _handler.HandleAsync(context);
// Assert
Assert.False(context.HasSucceeded, "HasSucceeded should be false because ma_username/ma_password take precedence");
_authenticationMock.Verify(a => a.Login("user", "wrong"), Times.Once);
_authenticationMock.Verify(a => a.Login("user", "pass"), Times.Never);
}
[Fact]
public async Task HandleAsync_AlreadyAuthenticated_Succeeds()
{
@ -88,6 +197,7 @@ public class SabnzbdHandlerTest
{
// Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
@ -108,6 +218,61 @@ public class SabnzbdHandlerTest
Assert.False(context.HasSucceeded, "HasSucceeded should be false for invalid credentials");
}
[Fact]
public async Task HandleAsync_InvalidApiKeyCredentials_DoesNotSucceed()
{
// Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
{
QueryString = new("?apikey=user:wrong")
}
};
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);
var context = CreateContext(httpContext);
_authenticationMock.Setup(a => a.Login("user", "wrong")).ReturnsAsync(SignInResult.Failed);
// Act
await _handler.HandleAsync(context);
// Assert
Assert.False(context.HasSucceeded, "HasSucceeded should be false for invalid api key credentials");
}
[Theory]
[InlineData("missingdelimiter")]
[InlineData(":pass")]
[InlineData("user:")]
public async Task HandleAsync_MalformedApiKey_DoesNotSucceed(String apiKey)
{
// Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
{
QueryString = new($"?apikey={apiKey}")
}
};
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);
var context = CreateContext(httpContext);
// Act
await _handler.HandleAsync(context);
// Assert
Assert.False(context.HasSucceeded, "HasSucceeded should be false for malformed api keys");
_authenticationMock.Verify(a => a.Login(It.IsAny<String>(), It.IsAny<String>()), Times.Never);
}
[Fact]
public async Task HandleAsync_MissingCredentials_DoesNotSucceed()
{

View file

@ -0,0 +1 @@
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View file

@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="SharpCompress" Version="[0.42.1]" />
<PackageReference Include="xunit" Version="2.9.3" />

View file

@ -1,6 +1,7 @@
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services;
namespace RdtClient.Service.Services;
namespace RdtClient.Web.Test;
internal sealed class TestSettings(DbSettings? settings = null) : ISettings
{

View file

@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.QBittorrent;
using RdtClient.Service.Services;
using RealDebridException = RDNET.RealDebridException;
using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Web.Controllers;
@ -14,7 +14,8 @@ namespace RdtClient.Web.Controllers;
[ApiController]
[Route("api/v2")]
[Route("qbittorrent/api/v2")]
public class QBittorrentController(ILogger<QBittorrentController> logger, QBittorrent qBittorrent, IHttpClientFactory httpClientFactory, ISettings settings) : Controller
public class QBittorrentController(ILogger<QBittorrentController> logger, QBittorrent qBittorrent, IHttpClientFactory httpClientFactory, ISettings settings, Torrents torrents)
: Controller
{
[AllowAnonymous]
[Route("/version/api")]
@ -368,30 +369,28 @@ public class QBittorrentController(ILogger<QBittorrentController> logger, QBitto
foreach (var url in urls)
{
try
Torrent? torrent;
if (url.StartsWith("magnet"))
{
if (url.StartsWith("magnet"))
{
await qBittorrent.TorrentsAddMagnet(url.Trim(), request.Category, null);
}
else if (url.StartsWith("http"))
{
var httpClient = httpClientFactory.CreateClient();
var result = await httpClient.GetByteArrayAsync(url);
await qBittorrent.TorrentsAddFile(result, request.Category, null);
}
else
{
return BadRequest($"Invalid torrent link format {url}");
}
torrent = await qBittorrent.TorrentsAddMagnet(url.Trim(), request.Category, null);
}
catch (RealDebridException ex)
else if (url.StartsWith("http"))
{
// Infringing file.
if (ex.ErrorCode == 35)
{
return Ok("Fails.");
}
var httpClient = httpClientFactory.CreateClient();
var result = await httpClient.GetByteArrayAsync(url);
torrent = await qBittorrent.TorrentsAddFile(result, request.Category, null);
}
else
{
return BadRequest($"Invalid torrent link format {url}");
}
var addResult = await WaitForTorrent(torrent.TorrentId);
if (!addResult)
{
return Ok("Fails.");
}
}
@ -412,7 +411,14 @@ public class QBittorrentController(ILogger<QBittorrentController> logger, QBitto
await file.CopyToAsync(target);
var fileBytes = target.ToArray();
await qBittorrent.TorrentsAddFile(fileBytes, request.Category, request.Priority);
var torrent = await qBittorrent.TorrentsAddFile(fileBytes, request.Category, request.Priority);
var addResult = await WaitForTorrent(torrent.TorrentId);
if (!addResult)
{
return Ok("Fails.");
}
}
}
@ -435,11 +441,11 @@ public class QBittorrentController(ILogger<QBittorrentController> logger, QBitto
}
var fileIds = request.Id
.Split('|', StringSplitOptions.RemoveEmptyEntries)
.Select(value => Int32.TryParse(value, out var parsedValue) ? parsedValue : (Int32?)null)
.Where(value => value.HasValue)
.Select(value => value!.Value)
.ToList();
.Split('|', StringSplitOptions.RemoveEmptyEntries)
.Select(value => Int32.TryParse(value, out var parsedValue) ? parsedValue : (Int32?)null)
.Where(value => value.HasValue)
.Select(value => value!.Value)
.ToList();
if (fileIds.Count == 0)
{
@ -671,6 +677,31 @@ public class QBittorrentController(ILogger<QBittorrentController> logger, QBitto
_ => String.Equals(torrent.State, filter, StringComparison.OrdinalIgnoreCase)
};
}
private async Task<Boolean> WaitForTorrent(Guid torrentId)
{
while (true)
{
var torrent = await torrents.GetById(torrentId);
if (torrent == null)
{
throw new($"Failed to add torrent: Not Found");
}
if (torrent.RdStatus == TorrentStatus.Error || torrent.Error != null)
{
return false;
}
if (torrent.RdStatus != TorrentStatus.Queued)
{
return true;
}
await Task.Delay(1000);
}
}
}
public class QBAuthLoginRequest
@ -686,7 +717,6 @@ public class QBTorrentsInfoRequest
public String? Hashes { get; set; }
}
public class QBTorrentsCountRequest
{
public String? Filter { get; set; }

View file

@ -56,22 +56,7 @@ public class SabnzbdController(ILogger<SabnzbdController> logger, Sabnzbd sabnzb
if (name == "delete")
{
var value = GetParam("value");
if (String.IsNullOrWhiteSpace(value))
{
return BadRequest(new SabnzbdResponse
{
Error = "No value specified for delete operation"
});
}
await sabnzbd.Delete(value ?? "");
return Ok(new SabnzbdResponse
{
Status = true
});
return await DeleteDownloads();
}
return Ok(new SabnzbdResponse
@ -85,6 +70,12 @@ public class SabnzbdController(ILogger<SabnzbdController> logger, Sabnzbd sabnzb
public async Task<ActionResult> History()
{
logger.LogDebug("Sabnzbd mode: history");
var name = GetParam("name");
if (name == "delete")
{
return await DeleteDownloads();
}
return Ok(new SabnzbdResponse
{
@ -183,6 +174,31 @@ public class SabnzbdController(ILogger<SabnzbdController> logger, Sabnzbd sabnzb
});
}
private async Task<ActionResult> DeleteDownloads()
{
var value = GetParam("value");
if (String.IsNullOrWhiteSpace(value))
{
return BadRequest(new SabnzbdResponse
{
Error = "No value specified for delete operation"
});
}
var deleteFiles = IsTrue(GetParam("del_files"));
foreach (var hash in value.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
await sabnzbd.Delete(hash, deleteFiles);
}
return Ok(new SabnzbdResponse
{
Status = true
});
}
private String? GetParam(String name)
{
var value = Request.Query[name].ToString();
@ -194,4 +210,9 @@ public class SabnzbdController(ILogger<SabnzbdController> logger, Sabnzbd sabnzb
return value;
}
private static Boolean IsTrue(String? value)
{
return value is "1" || Boolean.TryParse(value, out var result) && result;
}
}

View file

@ -3,7 +3,6 @@ using System.Reflection;
using Aria2NET;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RdtClient.Data.Data;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers;