Compare commits
21 commits
1645320199
...
858bd7c95c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
858bd7c95c | ||
|
|
2822653d3b | ||
|
|
e8a86cf806 | ||
|
|
38530cd6fd | ||
|
|
a7fb1e3e9d | ||
|
|
5c8a078927 | ||
|
|
f369e97d6e | ||
|
|
a9013e0c2a | ||
|
|
c6f6008591 | ||
|
|
b8e467652b | ||
|
|
e1635a43a9 | ||
|
|
f5cee6e2f3 | ||
|
|
767038528a | ||
|
|
a82e895a03 | ||
|
|
68edd4d80d | ||
|
|
d6f747c8d8 | ||
|
|
4eb34cbef9 | ||
|
|
5b7b526390 | ||
|
|
49ab603708 | ||
|
|
1166e9ba99 | ||
|
|
a86056a3ef |
42 changed files with 798 additions and 208 deletions
14
.devcontainer/Dockerfile
Normal file
14
.devcontainer/Dockerfile
Normal 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
|
||||
51
.devcontainer/devcontainer.json
Normal file
51
.devcontainer/devcontainer.json
Normal 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
16
.devcontainer/post-create.sh
Executable 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
13
.devcontainer/post-start.sh
Executable 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
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ server/RdtClient.Web/appsettings.Development.json
|
|||
data
|
||||
Dockerfile.dev
|
||||
test.bat
|
||||
.DS_Store
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
server/RdtClient.Service.Test/GlobalTestConfig.cs
Normal file
1
server/RdtClient.Service.Test/GlobalTestConfig.cs
Normal file
|
|
@ -0,0 +1 @@
|
|||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ public class TorrentDownloadRaceTests : IAsyncLifetime
|
|||
var torrentId = Guid.NewGuid();
|
||||
|
||||
await using var context = CreateContext();
|
||||
|
||||
context.Torrents.Add(new()
|
||||
{
|
||||
TorrentId = torrentId,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
1
server/RdtClient.Web.Test/GlobalTestConfig.cs
Normal file
1
server/RdtClient.Web.Test/GlobalTestConfig.cs
Normal file
|
|
@ -0,0 +1 @@
|
|||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue