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 data
Dockerfile.dev Dockerfile.dev
test.bat 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 ## [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 ## [2.0.134] - 2026-05-22
### Changed ### Changed
Upgraded torbox.net dependency. - Upgraded torbox.net dependency.
## [2.0.133] - 2026-05-17 ## [2.0.133] - 2026-05-17
### Added ### 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. Enter `6500` in the `Port` field.
1. Enable `Use SSL` only if you access RdtClient through HTTPS. 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. 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. 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. Set the category to `sonarr` for Sonarr or `radarr` for Radarr.
1. Hit `Test` and then `Save` if all is well. 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 - Visual Studio 2025
- (optional) Resharper - (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. 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. 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. 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; return DownloadAddResult.Added;
} }
// These shouldn't be possible any longer, but added for safety and until confirmed. // These shouldn't be possible any longer, but added for safety and until confirmed.
catch (DbUpdateException ex) 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)) 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; return DownloadAddResult.TorrentMissing;
} }
@ -273,7 +276,7 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
} }
public async Task Reset(Guid downloadId) public async Task Reset(Guid downloadId, DateTimeOffset? downloadQueued = null)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId) .FirstOrDefaultAsync(m => m.DownloadId == downloadId)
@ -282,7 +285,7 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.RetryCount = 0; dbDownload.RetryCount = 0;
dbDownload.Link = null; dbDownload.Link = null;
dbDownload.Added = DateTimeOffset.UtcNow; dbDownload.Added = DateTimeOffset.UtcNow;
dbDownload.DownloadQueued = DateTimeOffset.UtcNow; dbDownload.DownloadQueued = downloadQueued ?? DateTimeOffset.UtcNow;
dbDownload.DownloadStarted = null; dbDownload.DownloadStarted = null;
dbDownload.DownloadFinished = null; dbDownload.DownloadFinished = null;
dbDownload.UnpackingQueued = null; dbDownload.UnpackingQueued = null;

View file

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

View file

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

View file

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

View file

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

View file

@ -12,17 +12,17 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AllDebrid.NET" Version="1.0.18" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </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="Moq" Version="4.20.72" />
<PackageReference Include="SharpCompress" Version="[0.42.1]" /> <PackageReference Include="SharpCompress" Version="[0.42.1]" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.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.TestingHelpers" Version="22.1.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" 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" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@ -38,10 +38,4 @@
<ProjectReference Include="..\RdtClient.Service\RdtClient.Service.csproj" /> <ProjectReference Include="..\RdtClient.Service\RdtClient.Service.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Reference Include="TorBoxNET">
<HintPath>..\..\..\torbox-net\TorBoxNET\bin\Release\netstandard2.0\TorBoxNET.dll</HintPath>
</Reference>
</ItemGroup>
</Project> </Project>

View file

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

View file

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

View file

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

View file

@ -366,6 +366,64 @@ public class SabnzbdTest
Assert.Equal("Failed", result.Slots[0].Status); 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] [Fact]
public void GetConfig_ShouldReturnCorrectConfig() public void GetConfig_ShouldReturnCorrectConfig()
{ {

View file

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

View file

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

View file

@ -1,6 +1,7 @@
using RdtClient.Data.Models.Internal; 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 internal sealed class TestSettings(DbSettings? settings = null) : ISettings
{ {

View file

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

View file

@ -65,6 +65,36 @@ public class SabnzbdHandler(Authentication authentication, IHttpContextAccessor
return; 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 // Authentication required but missing credentials
context.Fail(); context.Fail();
} }

View file

@ -26,7 +26,7 @@
<PackageReference Include="Synology.Api.Client" Version="[0.3.93]" /> <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" Version="22.1.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" 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>
<ItemGroup> <ItemGroup>

View file

@ -12,7 +12,8 @@ using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services.DebridClients; 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"; 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) private static String FormatPremiumizeError(RawTransferCreateResponse result)
{ {
return String.Join(": ", new[] return String.Join(": ",
{ new[]
result.Code, {
result.Message result.Code, result.Message
}.Where(m => !String.IsNullOrWhiteSpace(m))); }.Where(m => !String.IsNullOrWhiteSpace(m)));
} }
private static Boolean IsRateLimitMessage(String message) private static Boolean IsRateLimitMessage(String message)

View file

@ -12,7 +12,8 @@ using Torrent = RDNET.Torrent;
namespace RdtClient.Service.Services.DebridClients; 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; private TimeSpan? _offset;

View file

@ -11,7 +11,12 @@ using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services.DebridClients; 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 : IDebridClient
{ {
private const String TorBoxApiHost = "api.torbox.app"; private const String TorBoxApiHost = "api.torbox.app";
@ -69,9 +74,14 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
return await HandleAddTorrentErrors(async asQueued => return await HandleAddTorrentErrors(async asQueued =>
{ {
var user = await GetClient().User.GetAsync(true); 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 => return await HandleAddTorrentErrors(async asQueued =>
{ {
var user = await GetClient().User.GetAsync(true); 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 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 else
{ {
var torrentId = await HandleErrors(() => GetClient().Torrents.GetHashInfoAsync(torrent.Hash, true)); if (torrent.RdId == null)
id = torrentId?.Id; {
return null;
}
id = Int32.Parse(torrent.RdId);
} }
if (id == null) if (id == null)
@ -372,7 +391,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
} }
var httpClient = httpClientFactory.CreateClient(clientId); var httpClient = httpClientFactory.CreateClient(clientId);
var torBoxNetClient = new TorBoxNetClient(null, httpClient); var torBoxNetClient = new TorBoxNetClient(null, httpClient, retryCount: 5);
torBoxNetClient.UseApiAuthentication(apiKey); torBoxNetClient.UseApiAuthentication(apiKey);
// Get the server time to fix up the timezones on results // Get the server time to fix up the timezones on results
@ -441,7 +460,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{ {
return new() return new()
{ {
Id = torrent.Hash, Id = torrent.Id.ToString(),
Filename = torrent.Name, Filename = torrent.Name,
OriginalFilename = torrent.Name, OriginalFilename = torrent.Name,
Hash = torrent.Hash, Hash = torrent.Hash,
@ -513,7 +532,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{ {
throw rateLimitException; 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)); coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2));
@ -541,7 +560,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{ {
throw rateLimitException; 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)); coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2));
@ -560,6 +579,12 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
return await HandleErrors(() => action(false)); 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) private async Task<String> HandleAddUsenetErrors(Func<Boolean, Task<String>> action)
{ {
return await HandleErrors(() => action(false)); return await HandleErrors(() => action(false));
@ -590,7 +615,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
} }
else else
{ {
var result = await GetClient().Torrents.GetHashInfoAsync(id, true); var result = await GetClient().Torrents.GetIdInfoAsync(Int32.Parse(id), true);
if (result != null) if (result != null)
{ {

View file

@ -86,8 +86,8 @@ public class Downloads(DownloadData downloadData) : IDownloads
await downloadData.DeleteForTorrent(torrentId); 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 UpdateRetryCount(Guid downloadId, Int32 retryCount);
Task UpdateRemoteId(Guid downloadId, String remoteId); Task UpdateRemoteId(Guid downloadId, String remoteId);
Task DeleteForTorrent(Guid torrentId); 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 var topLevelSelectedFiles = torrent.Files
.Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path)) .Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path))
.Select(m => m.Path.Trim('/').Trim('\\')) .Select(m => m.Path.Trim('/').Trim('\\'))
.Where(m => m.IndexOfAny(['/', '\\']) < 0) .Where(m => m.IndexOfAny(['/', '\\']) < 0)
.Select(Path.GetFileName) .Select(Path.GetFileName)
.Where(m => !String.IsNullOrWhiteSpace(m)) .Where(m => !String.IsNullOrWhiteSpace(m))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (topLevelSelectedFiles.Count == 1) if (topLevelSelectedFiles.Count == 1)
{ {
var selectedFileName = topLevelSelectedFiles[0]!; var selectedFileName = topLevelSelectedFiles[0]!;
var selectedFileBaseName = Path.GetFileNameWithoutExtension(selectedFileName); var selectedFileBaseName = Path.GetFileNameWithoutExtension(selectedFileName);
if (torrent.ClientKind == Provider.TorBox)
{
return selectedFileBaseName;
}
if (!String.IsNullOrWhiteSpace(selectedFileBaseName) && if (!String.IsNullOrWhiteSpace(selectedFileBaseName) &&
selectedFileBaseName.Equals(torrent.RdName, StringComparison.OrdinalIgnoreCase)) 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; var progress = torrent.Completed.HasValue || torrent.RdStatus == TorrentStatus.Finished ? 1f : 0f;
return torrent.Files return torrent.Files
.Select((file, index) => new TorrentFileItem .Select((file, index) => new TorrentFileItem
{ {
Index = index, Index = index,
Name = file.Path, Name = file.Path,
Size = file.Bytes, Size = file.Bytes,
Progress = file.Selected ? progress : 0f, Progress = file.Selected ? progress : 0f,
Priority = file.Selected ? 1 : 0, Priority = file.Selected ? 1 : 0,
IsSeed = false IsSeed = false
}) })
.ToList(); .ToList();
} }
public async Task<TorrentProperties?> TorrentProperties(String hash) 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}"); 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) 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}"); 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) 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) 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; 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); 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) switch (settings.Current.Integrations.Default.FinishedAction)
{ {
case TorrentFinishedAction.RemoveAllTorrents: case TorrentFinishedAction.RemoveAllTorrents:
logger.LogDebug("Removing nzb from debrid provider and RDT-Client, no files"); logger.LogDebug("Removing nzb from debrid provider and RDT-Client, {Files}", deleteFiles ? "with files" : "no files");
await torrents.Delete(torrent.TorrentId, true, true, true); await torrents.Delete(torrent.TorrentId, true, true, deleteFiles);
break; break;
case TorrentFinishedAction.RemoveRealDebrid: case TorrentFinishedAction.RemoveRealDebrid:
logger.LogDebug("Removing nzb from debrid provider, no files"); logger.LogDebug("Removing nzb from debrid provider, {Files}", deleteFiles ? "with files" : "no files");
await torrents.Delete(torrent.TorrentId, false, true, true); await torrents.Delete(torrent.TorrentId, false, true, deleteFiles);
break; break;
case TorrentFinishedAction.RemoveClient: case TorrentFinishedAction.RemoveClient:
logger.LogDebug("Removing nzb from client, no files"); logger.LogDebug("Removing nzb from client, {Files}", deleteFiles ? "with files" : "no files");
await torrents.Delete(torrent.TorrentId, true, false, true); await torrents.Delete(torrent.TorrentId, true, false, deleteFiles);
break; break;
case TorrentFinishedAction.None: case TorrentFinishedAction.None:

View file

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

View file

@ -367,8 +367,10 @@ public class TorrentRunner(
} }
// Process torrents in DebridQueue // 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) var torrentsToAddToProvider = allTorrents
.ToList(); .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) if (torrentsToAddToProvider.Count != 0)
{ {
@ -477,7 +479,11 @@ public class TorrentRunner(
{ {
// Check if there are any downloads that are queued and can be started. // Check if there are any downloads that are queued and can be started.
var queuedDownloads = torrent.Downloads 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) .OrderBy(m => m.DownloadQueued)
.ToList(); .ToList();
@ -528,10 +534,24 @@ public class TorrentRunner(
{ {
logger.LogError(ex, "Cannot unrestrict link: {ex.Message}", ex.Message); logger.LogError(ex, "Cannot unrestrict link: {ex.Message}", ex.Message);
await downloads.UpdateError(download.DownloadId, ex.Message); if (download.RetryCount < torrent.DownloadRetryAttempts)
await downloads.UpdateCompleted(download.DownloadId, DateTimeOffset.UtcNow); {
download.Error = ex.Message; var retryCount = download.RetryCount + 1;
download.Completed = DateTimeOffset.UtcNow; 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; 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) private void Log(String message, Download? download, Torrent? torrent)
{ {
if (download != null) if (download != null)

View file

@ -664,6 +664,38 @@ public class Torrents(
{ {
torrentsByRdId.TryGetValue(rdTorrent.Id, out var torrent); 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 // Auto import torrents only torrents that have their files selected
if (torrent == null && settings.Current.Provider.AutoImport) if (torrent == null && settings.Current.Provider.AutoImport)
{ {
@ -1137,18 +1169,19 @@ public class Torrents(
torrent.RdFiles); torrent.RdFiles);
} }
private readonly record struct TorrentRdState(String? RdName, private readonly record struct TorrentRdState(
Int64? RdSize, String? RdName,
String? RdHost, Int64? RdSize,
Int64? RdSplit, String? RdHost,
Int64? RdProgress, Int64? RdSplit,
TorrentStatus? RdStatus, Int64? RdProgress,
String? RdStatusRaw, TorrentStatus? RdStatus,
DateTimeOffset? RdAdded, String? RdStatusRaw,
DateTimeOffset? RdEnded, DateTimeOffset? RdAdded,
Int64? RdSpeed, DateTimeOffset? RdEnded,
Int64? RdSeeders, Int64? RdSpeed,
String? RdFiles); Int64? RdSeeders,
String? RdFiles);
private void Log(String message, Download? download, Torrent? torrent) private void Log(String message, Download? download, Torrent? torrent)
{ {

View file

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

View file

@ -108,6 +108,30 @@ public class SabnzbdControllerTest
Assert.IsType<OkObjectResult>(result); 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] [Fact]
public async Task GetHistory_ReturnsOk() public async Task GetHistory_ReturnsOk()
{ {
@ -129,6 +153,26 @@ public class SabnzbdControllerTest
Assert.Equal(1, response.History.NoOfSlots); 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] [Fact]
public void GetVersion_HasAllowAnonymousAttribute() public void GetVersion_HasAllowAnonymousAttribute()
{ {

View file

@ -44,6 +44,7 @@ public class SabnzbdHandlerTest
{ {
// Arrange // Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword; _settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext var httpContext = new DefaultHttpContext
{ {
Request = Request =
@ -64,6 +65,114 @@ public class SabnzbdHandlerTest
Assert.True(context.HasSucceeded, "HasSucceeded should be true for valid credentials"); 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] [Fact]
public async Task HandleAsync_AlreadyAuthenticated_Succeeds() public async Task HandleAsync_AlreadyAuthenticated_Succeeds()
{ {
@ -88,6 +197,7 @@ public class SabnzbdHandlerTest
{ {
// Arrange // Arrange
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword; _settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext var httpContext = new DefaultHttpContext
{ {
Request = Request =
@ -108,6 +218,61 @@ public class SabnzbdHandlerTest
Assert.False(context.HasSucceeded, "HasSucceeded should be false for invalid credentials"); 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] [Fact]
public async Task HandleAsync_MissingCredentials_DoesNotSucceed() public async Task HandleAsync_MissingCredentials_DoesNotSucceed()
{ {

View file

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

View file

@ -11,7 +11,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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="Moq" Version="4.20.72" />
<PackageReference Include="SharpCompress" Version="[0.42.1]" /> <PackageReference Include="SharpCompress" Version="[0.42.1]" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />

View file

@ -1,6 +1,7 @@
using RdtClient.Data.Models.Internal; 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 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.Enums;
using RdtClient.Data.Models.QBittorrent; using RdtClient.Data.Models.QBittorrent;
using RdtClient.Service.Services; using RdtClient.Service.Services;
using RealDebridException = RDNET.RealDebridException; using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Web.Controllers; namespace RdtClient.Web.Controllers;
@ -14,7 +14,8 @@ namespace RdtClient.Web.Controllers;
[ApiController] [ApiController]
[Route("api/v2")] [Route("api/v2")]
[Route("qbittorrent/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] [AllowAnonymous]
[Route("/version/api")] [Route("/version/api")]
@ -368,30 +369,28 @@ public class QBittorrentController(ILogger<QBittorrentController> logger, QBitto
foreach (var url in urls) foreach (var url in urls)
{ {
try Torrent? torrent;
if (url.StartsWith("magnet"))
{ {
if (url.StartsWith("magnet")) torrent = await qBittorrent.TorrentsAddMagnet(url.Trim(), request.Category, null);
{
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}");
}
} }
catch (RealDebridException ex) else if (url.StartsWith("http"))
{ {
// Infringing file. var httpClient = httpClientFactory.CreateClient();
if (ex.ErrorCode == 35) var result = await httpClient.GetByteArrayAsync(url);
{ torrent = await qBittorrent.TorrentsAddFile(result, request.Category, null);
return Ok("Fails."); }
} 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); await file.CopyToAsync(target);
var fileBytes = target.ToArray(); 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 var fileIds = request.Id
.Split('|', StringSplitOptions.RemoveEmptyEntries) .Split('|', StringSplitOptions.RemoveEmptyEntries)
.Select(value => Int32.TryParse(value, out var parsedValue) ? parsedValue : (Int32?)null) .Select(value => Int32.TryParse(value, out var parsedValue) ? parsedValue : (Int32?)null)
.Where(value => value.HasValue) .Where(value => value.HasValue)
.Select(value => value!.Value) .Select(value => value!.Value)
.ToList(); .ToList();
if (fileIds.Count == 0) if (fileIds.Count == 0)
{ {
@ -671,6 +677,31 @@ public class QBittorrentController(ILogger<QBittorrentController> logger, QBitto
_ => String.Equals(torrent.State, filter, StringComparison.OrdinalIgnoreCase) _ => 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 public class QBAuthLoginRequest
@ -686,7 +717,6 @@ public class QBTorrentsInfoRequest
public String? Hashes { get; set; } public String? Hashes { get; set; }
} }
public class QBTorrentsCountRequest public class QBTorrentsCountRequest
{ {
public String? Filter { get; set; } public String? Filter { get; set; }

View file

@ -56,22 +56,7 @@ public class SabnzbdController(ILogger<SabnzbdController> logger, Sabnzbd sabnzb
if (name == "delete") if (name == "delete")
{ {
var value = GetParam("value"); return await DeleteDownloads();
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 Ok(new SabnzbdResponse return Ok(new SabnzbdResponse
@ -85,6 +70,12 @@ public class SabnzbdController(ILogger<SabnzbdController> logger, Sabnzbd sabnzb
public async Task<ActionResult> History() public async Task<ActionResult> History()
{ {
logger.LogDebug("Sabnzbd mode: history"); logger.LogDebug("Sabnzbd mode: history");
var name = GetParam("name");
if (name == "delete")
{
return await DeleteDownloads();
}
return Ok(new SabnzbdResponse 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) private String? GetParam(String name)
{ {
var value = Request.Query[name].ToString(); var value = Request.Query[name].ToString();
@ -194,4 +210,9 @@ public class SabnzbdController(ILogger<SabnzbdController> logger, Sabnzbd sabnzb
return value; 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 Aria2NET;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using RdtClient.Data.Data;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal; using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers; using RdtClient.Service.Helpers;