Compare commits

...

101 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
Roger Far
1645320199
Merge pull request #977 from ALenfant/antonin/improve_usenet
feat(usenet): improve NZB status messages and documentation
2026-05-25 20:22:08 -06:00
Roger Far
0e00062992
Merge pull request #976 from ALenfant/antonin/fix_parallel_tests
fix(tests): restore parallel execution
2026-05-25 20:21:39 -06:00
Roger Far
157fc8b2f2
Update CHANGELOG for version 2.0.134
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
Upgraded torbox.net dependency in the changelog.
2026-05-22 20:32:46 -06:00
Roger Far
45279115dc
Merge pull request #979 from asylumexp/main
Update TorBox.NET to 1.8.1
2026-05-22 20:28:01 -06:00
Sam Heinz
489bf6d7e0
Update TorBox.NET to 1.8.1 2026-05-23 12:21:27 +10:00
Sam Heinz
5518e7f06b
Update TorBox test mocks for limit parameter 2026-05-23 12:07:27 +10:00
Sam Heinz
84cbd5056f
Update TorBox.NET to 1.8.0 2026-05-23 11:59:13 +10:00
Antonin Lenfant-Kodia
c769bd08f0 feat(usenet): improve NZB status messages and documentation
- Update README.md to include Premiumize as a Usenet provider
- Add documentation for Sonarr/Radarr SABnzbd connection
- Update status messages to use 'NZB' instead of 'Torrent' for Usenet downloads in frontend and backend
2026-05-18 22:37:14 +02:00
Antonin Lenfant-Kodia
8142225ee9 fix(tests): restore parallel execution 2026-05-18 21:30:31 +02:00
Roger Far
284ef44e30
Merge pull request #975 from ALenfant/fix/ci-failure-974
Fix race condition causing CI failure
2026-05-17 15:34:39 -06:00
Antonin Lenfant-Kodia
fe6a2da266 fix(ci): Update xUnit DisableTestParallelization syntax 2026-05-17 23:31:17 +02:00
Antonin Lenfant-Kodia
8e49e500ea fix(ci): Resolve race conditions in tests and fix unused variable warning
- Refactored Torrents.cs to use local DbSettings instead of global Settings.Get

- Removed unused exception variable in Torrents.cs

- Disabled xUnit parallelization globally to prevent test race conditions caused by shared global state

- Enforced sequential test execution in CI workflow
2026-05-17 23:26:17 +02:00
Roger Far
b292df22fe 2.0.133
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-17 14:18:01 -06:00
Roger Far
7aa6c03fee Cleanup. 2026-05-17 14:17:13 -06:00
Roger Far
62e286104a
Merge pull request #974 from ALenfant/main
Add Premiumize NZB support
2026-05-17 14:13:07 -06:00
Roger Far
bd48ea02a0 Upgrade Torbox dependency.
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-17 13:58:46 -06:00
Roger Far
bc9fb89ecb
Merge pull request #967 from jwmann/feature/FixQBittorrentImportPath
Fix multiple qBittorrent Import Path issues for Completed Torrent & Fix compatibility with Sonarr
2026-05-17 13:51:17 -06:00
Antonin Lenfant-Kodia
d68b8b9bfe Add Premiumize NZB support 2026-05-17 21:49:21 +02:00
jwmann
cc977cfe29
Merge branch 'main' into feature/FixQBittorrentImportPath 2026-05-15 18:29:27 -04:00
Roger Far
4afee6938b 2.0.131
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-14 19:33:30 -06:00
Roger Far
6a4603617b
Merge pull request #970 from jwmann/feature/FixTorrentDeletionOnError
Fix DeleteOnError provider retry loop, which causes the delete on error feature to never run
2026-05-14 15:20:40 -06:00
jwmann
a62d84779e
Fix DeleteOnError provider retry loop 2026-05-14 16:48:38 -04:00
jwmann
6c5d1fb39d
Remove duplicate import 2026-05-01 15:28:58 -04:00
Roger Far
f5ea1e0f10 2.0.130
Some checks failed
Create GitHub Release / Test, Build, and Bundle (push) Has been cancelled
dotnet test / build (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
Release Docker Image / push-images (push) Has been cancelled
Create GitHub Release / Create GitHub release (push) Has been cancelled
2026-04-28 20:38:40 -06:00
Roger Far
75ad8e848d Merge branch 'main' of https://github.com/rogerfar/rdt-client 2026-04-28 20:30:19 -06:00
Roger Far
2d8983050d
Merge pull request #966 from omgbeez/upstream/db-close-fix
fix(service): Clear SQLite connection pools on service stop
2026-04-28 20:30:13 -06:00
jwmann
cd1310c1e9
Add additional compatibility when a Torrent stores its media file within a Sub Folder 2026-04-28 21:20:40 -04:00
jwmann
7c1169d0dd
Fix QBit Import Path for Single File Torrents 2026-04-28 21:20:28 -04:00
omgbeez
9ea2219df7 fix(service): Clear SQLite connection pools on service stop
Should fix manual delete of Regression tests which create a temp SQLite DB for their tests.
2026-04-28 14:31:29 -04:00
Roger Far
fd5ece0e4d Update packages. 2026-04-27 21:21:50 -06:00
Roger Far
8c7c384bce Cleanup. 2026-04-27 21:18:33 -06:00
Roger Far
7f60f9de5a Cleanup, fixed failing build. 2026-04-27 21:04:26 -06:00
Roger Far
5d09053327 Merge branch 'main' of https://github.com/rogerfar/rdt-client 2026-04-27 21:00:50 -06:00
Roger Far
f2bfae5f85
Merge pull request #960 from omgbeez/upstream/perf-1
Few cleanups and perf fixes
2026-04-27 21:00:41 -06:00
Roger Far
3d21ca245c Merge branch 'main' of https://github.com/rogerfar/rdt-client 2026-04-27 20:12:38 -06:00
Roger Far
d6bf1845d8
Merge pull request #961 from omgbeez/upstream/race-fix
fix: Race conditions when deleting torrent and adding downloads.
2026-04-27 20:12:20 -06:00
Roger Far
147b1db4a5 no message 2026-04-27 20:10:34 -06:00
Roger Far
b037809c0e
Merge pull request #963 from jwmann/feature/qbittorrent-compatibility
Update & Fix qBittorrent compatibility for CleanupArr workflows and other qBit implementations
2026-04-27 20:09:15 -06:00
Roger Far
32aa57a561 Upgrade packages, fixed unit tests on Windows. 2026-04-27 20:08:07 -06:00
Roger Far
4dd6ae5626
Merge pull request #964 from XanderLuciano/bump-bezzad-version
Bump bezzad version
2026-04-27 19:56:44 -06:00
Claude
4920700853
chore: bump Downloader (bezzad) to 5.3.0
https://claude.ai/code/session_01N6b27qWMmqoJLjipXCwa2i
2026-04-27 02:11:05 +00:00
omgbeez
1efc682cca Undo local changes ... again 2026-04-25 11:48:36 -04:00
jwmann
28b7c79a6b
Allow Moq to intercept TorrentInfo in tests 2026-04-24 00:09:00 -04:00
jwmann
797c58e654
Fix qBittorrent compatibility for CleanupArr workflows 2026-04-23 22:13:38 -04:00
omgbeez
63fd8caee9 fix: Race conditions when deleting/updating a torrent and its downloadable files 2026-04-23 00:22:09 -04:00
omgbeez
5c71ecc211 Mem and CPU perf fixes 2026-04-21 22:19:06 -04:00
Roger Far
145ea9a8ff
Merge pull request #959 from omgbeez/upstream/fix-tests-macos
fix(tests): Fix tests to work on macos (and linux, and windows)
2026-04-21 07:23:15 -06:00
Clifford Roche
d576ee7f01 fix(tests): Make the test fixtures os independant 2026-04-20 22:11:42 -04:00
omgbeez
8664993ff6 fix(tests): Fix tests to work on macos (and linux, and windows)
Windows is the odd OS out here, adjusting logic for test data.
2026-04-20 19:44:07 -04:00
Roger Far
2627e03e97
Merge pull request #955 from anoraktrend/patch-1
Update README.md
2026-04-13 13:03:12 -06:00
Lucy Ada Randall
9c4ddb66ad
Update README.md
fix some missing things.
2026-04-07 18:59:43 -07:00
Roger Far
0abdde346d 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
Create GitHub Release / Create GitHub release (push) Has been cancelled
Release Docker Image / push-images (push) Has been cancelled
2026-04-06 20:40:24 -06:00
Roger Far
ea9fc0b167
Merge pull request #953 from sylvaindd/mobile-torrent-view
Improve mobile layout and responsiveness for torrent components
2026-04-06 20:39:33 -06:00
Roger Far
75e02cfb82 Fixed some qBittorrent endpoints.
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-04-06 20:38:32 -06:00
Sylvain DUARTE
59cff632eb
Improve mobile layout and responsiveness for torrent components 2026-03-30 17:24:20 +02:00
Roger Far
53f985d80d v2.0.127
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-03-15 12:18:38 -06:00
Roger Far
f717f18f8e
Merge pull request #950 from vinodmishra/main
Add state filter to info endpoint and also implement the count endpoint
2026-03-15 10:48:55 -06:00
Vinod Mishra
d4e1502c60 Add state filter to info endpoint and also implement the count endpoint 2026-03-15 13:12:59 +00:00
Roger Far
5054ca4ae1 2.0.126
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-03-14 13:44:53 -06:00
Roger Far
66bd4560a8 Run formatters. 2026-03-14 13:41:30 -06:00
Roger Far
683ac44c12 Upgrade packages.
Migrate Angular to new injector constructors.
2026-03-14 13:39:51 -06:00
Roger Far
351e1eb7c8
Merge pull request #949 from sylvaindd/mobile-torrent-table
Add mobile compatibility for torrent-table
2026-03-14 13:28:26 -06:00
Roger Far
390ab390da
Merge pull request #948 from sylvaindd/categories-select
Add categories list from settings in add-new-torrent
2026-03-14 13:28:00 -06:00
Roger Far
042b1e32e4
Merge pull request #946 from jfrconley/fix/torrents-info-hashes-filter
Fix /api/v2/torrents/info to filter by hashes query parameter
2026-03-14 13:27:13 -06:00
Roger Far
095a25f04a
Merge pull request #947 from AlexandreVassard/main
fix(symlink): strip duplicate torrent name from subpath
2026-03-14 13:26:56 -06:00
Roger Far
a4706a5830
Merge pull request #945 from omgbeez/upstream/even-better-ratelimitng
feat: Improved rate limit handling
2026-03-14 13:21:43 -06:00
Sylvain DUARTE
cf278e2964
Implement category dropdown for add-new-torrent component 2026-03-14 18:25:03 +01:00
Sylvain DUARTE
0d6369750f
Enhance mobile responsiveness of torrent table component 2026-03-14 18:24:31 +01:00
omgbeez
76dc3e0b98 fix: Long retry-after would generate timeout instead of RateLimitException
Needed to move the retry handler before the TimeOut handler, and drive the throw from the TimeOut configured by the user for the provider.
2026-03-14 09:32:51 -04:00
Sylvain DUARTE
e2195944e4
Add categories list from settings in add-new-torrent
(cherry picked from commit 20b08c52a73f1f2725d96a520c7cdd511906b8ef)
2026-03-14 11:48:45 +01:00
Sylvain DUARTE
004ed45144
Add mobile compatibility for torrent-table
(cherry picked from commit 65e843489dfefcb621b3874e55e81dc8cbd3ffb3)
2026-03-14 11:48:24 +01:00
Alexandre Vassard
6743bd472e fix(symlink): normalize subpath first component before comparing torrent name prefix
The previous fix compared subPath directly against the sanitized torrent
name, which fails when RdName contains characters removed by
RemoveInvalidPathChars (e.g. ':' on Windows). Extract and normalize the
first component of subPath before comparing so both sides go through the
same sanitization. Add tests covering the duplicate-prefix stripping case.
2026-03-08 21:29:33 +01:00
Alexandre Vassard
1dd7ab9c8e fix(symlink): strip duplicate torrent name from subpath
torrent.Files[].Path already includes the torrent name as its first
component (e.g. "Hijack/Saison 1/file.mkv"), but GetDownloadPath was
also initialising torrentPath with torrent.RdName, causing the name
to appear twice (e.g. "Hijack/Hijack/Saison 1"). Strip the torrent
name prefix from subPath before combining so the resolved path matches
the actual rclone mount layout.
2026-03-08 21:15:34 +01:00
John Conley
c0bbab5fa4 Fix /api/v2/torrents/info to filter by hashes query parameter
Add support for the `hashes` query parameter (pipe-separated, case-insensitive)
to match real qBittorrent API behavior. This fixes compatibility with cleanuparr
and *arr apps that rely on hash-based filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:39:42 -08:00
omgbeez
41787077db Add remaining seconds display during rate-limit and improve cooldown handling 2026-03-07 18:04:12 -05:00
omgbeez
67afe189b5 Refactor TorBoxDebridClientTest to include IRateLimitCoordinator and improve test consistency across methods. Updated logging and error handling for rate-limiting scenarios. 2026-03-07 17:57:42 -05:00
Roger Far
b7090eeb3a 2.0.125
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-03-01 11:25:15 -07:00
Roger Far
0b726792cb Add torrents/trackers qBittorrent endpoint for cleanuparr compatibility. 2026-03-01 11:24:11 -07:00
Roger Far
7ec977b36e
Merge pull request #941 from ALenfant/fix_login_format
Always return plain text from `auth/login` API endpoint
2026-03-01 10:38:37 -07:00
Antonin Lenfant-Kodia
a7dd01eb42 Fix qB auth/login to always return plain text 2026-03-01 15:01:13 +01:00
109 changed files with 5784 additions and 3134 deletions

14
.devcontainer/Dockerfile Normal file
View file

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

View file

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

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

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

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

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

1
.gitignore vendored
View file

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

View file

@ -6,6 +6,76 @@ 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.
## [2.0.133] - 2026-05-17
### Added
- Added NZB support for Premiumize, thanks to @ALenfant!
## [2.0.132] - 2026-05-17
### Changed
- Fixed for Cleanuparr
- Upgrade Torbox.NET dependency
## [2.0.131] - 2026-05-14
### Changed
- Fixes to the rate limiting.
## [2.0.130] - 2026-04-28
### Changed
- Fixes to unit tests
- Upgraded .NET and Angular dependencies
- Added better qBittorrent capabilities for Cleanuparr
- Performance fixes
## [2.0.129] - 2026-04-06
### Added
- Optimized mobile views, thanks to @sylvaindd!
## [2.0.128] - 2026-04-06
### Changed
- Fixed some qBittorrent endpoints.
## [2.0.127] - 2026-03-15
### Added
- Implement qBittorrent endpoint for counting torrents, thanks to @vinodmishra!
## [2.0.126] - 2026-03-14
### Added
- qBittorrent hash filtering on the /torrrents/info endpoint, thanks to @jfrconley!
- Category select as a dropdown when adding a new torrent, thanks to @sylvaindd!
### Changed
- Rate limiting fixes, thanks to @omgbeez!
- AllDebrid Symlink fixes, thanks to @AlexandreVassard!
## [2.0.125] - 2026-03-01
### Added
- Added extra qBittorrent endpoints for Cleanuparr compatibility.
## [2.0.124] - 2026-02-23
### Added
- Added /version/api for qBittorrent to support clients that still want to talk to the old API.

View file

@ -1,15 +1,15 @@
# Real-Debrid Torrent & Usenet Client
This is a web interface to manage your torrents on Real-Debrid, AllDebrid, Premiumize TorBox or DebridLink. It supports the following features:
This is a web interface to manage your torrents on Real-Debrid, AllDebrid, Premiumize, TorBox or DebridLink. It supports the following features:
- Add new torrents through magnets or files
- Add usenet downloads through NZB files (TorBox only)
- Add usenet downloads through NZB files (TorBox and Premiumize only)
- Download all files from Real-Debrid, AllDebrid, Premiumize or TorBox to your local machine automatically
- Unpack all files when finished downloading
- Implements a fake qBittorrent API so you can hook up other applications like Sonarr, Radarr or Couchpotato.
- Built with Angular 21 and .NET 10
**You will need a Premium service at Real-Debrid, AllDebrid, Premiumize or Torbox!**
**You will need a Premium service at Real-Debrid, AllDebrid, Premiumize, Torbox or DebridLink!**
[Click here to sign up for Real-Debrid.](https://real-debrid.com/?id=1348683)
@ -166,7 +166,9 @@ It has the following options:
### Connecting Sonarr/Radarr
RdtClient emulates the qBittorrent web protocol and allow applications to use those APIs. This way you can use Sonarr and Radarr to download directly from RealDebrid.
RdtClient emulates the qBittorrent web protocol and allows applications to use those APIs. This way you can use Sonarr and Radarr to download directly from RealDebrid.
#### Torrents
1. Login to Sonarr or Radarr and click `Settings`.
1. Go to the `Download Client` tab and click the plus to add.
@ -183,6 +185,24 @@ When downloading files it will append the `category` setting in the Sonarr/Radar
Notice: the progress and ETA reported in Sonarr's Activity tab will not be accurate, but it will report the torrent as completed so it can be processed after it is done downloading.
#### Usenet/NZB
RdtClient also emulates part of the SABnzbd API so Sonarr and Radarr can add NZB downloads. This requires a provider that supports Usenet/NZB downloads, currently TorBox or Premiumize.
1. Login to Sonarr or Radarr and click `Settings`.
1. Go to the `Download Client` tab and click the plus to add.
1. Click `SABnzbd` in the list.
1. Enter the IP or hostname of RdtClient in the `Host` field.
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. 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.
When importing completed NZB downloads, Sonarr/Radarr must be able to access the path reported by RdtClient. In Docker setups this may require a Remote Path Mapping from the RdtClient download path to the path mounted inside Sonarr/Radarr.
### Running within a folder
By default the application runs in the root of your hosted address (i.e. https://rdt.myserver.com/), but if you want to run it as a relative folder (i.e. https://myserver.com/rdt) you will have to change the `BasePath` setting in the `appsettings.json` file. You can set the `BASE_PATH` environment variable for docker enviroments.
@ -198,6 +218,17 @@ By default the application runs in the root of your hosted address (i.e. https:/
- Visual Studio 2025
- (optional) Resharper
### Dev Container
The repository includes a dev container under `.devcontainer/` for the split development workflow used by this project.
It installs .NET 10 and Node 22, forwards ports `4200` and `6500`, and persists `/data/db` and `/data/downloads` in named volumes so the local SQLite database, logs, and downloads survive container rebuilds.
1. Open the repository in the dev container.
1. In one terminal run `dotnet watch run --project server/RdtClient.Web`.
1. In another terminal run `cd client && npm start`.
1. Open `http://localhost:4200`. The Angular dev server proxies `/Api` and `/hub` to the backend running on `6500`.
1. Open the client folder project in VS Code and run `npm install`.
1. To debug run `ng serve`, to build run `ng build -c production`.
1. Open the Visual Studio 2025 project `RdtClient.sln` and `Publish` the `RdtClient.Web` to the given `PublishFolder` target.

View file

@ -1,14 +1,14 @@
const minorOnly = ['eslint'];
const minorOnly = ["eslint", "typescript"];
const patchOnly = [];
module.exports = {
target: (name) => {
if (minorOnly.indexOf(name) > -1) {
return 'minor';
return "minor";
}
if (patchOnly.indexOf(name) > -1) {
return 'patch';
return "patch";
}
return 'latest';
}
return "latest";
},
};

2615
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,46 +8,47 @@
"watch": "ng build --watch --configuration development",
"update": "ng update --force --allow-dirty @angular/cli @angular/core @angular/cdk",
"lint": "ng lint",
"prettier": "prettier --write \"./**/*.{ts,html,json}\""
"prettier": "prettier --write .",
"precommit": "npm run prettier && npm run lint"
},
"private": true,
"dependencies": {
"@angular/animations": "^21.1.4",
"@angular/cdk": "^21.1.4",
"@angular/common": "^21.1.4",
"@angular/compiler": "^21.1.4",
"@angular/core": "^21.1.4",
"@angular/forms": "^21.1.4",
"@angular/platform-browser": "^21.1.4",
"@angular/platform-browser-dynamic": "^21.1.4",
"@angular/router": "^21.1.4",
"@angular/animations": "^21.2.10",
"@angular/cdk": "^21.2.8",
"@angular/common": "^21.2.10",
"@angular/compiler": "^21.2.10",
"@angular/core": "^21.2.10",
"@angular/forms": "^21.2.10",
"@angular/platform-browser": "^21.2.10",
"@angular/platform-browser-dynamic": "^21.2.10",
"@angular/router": "^21.2.10",
"@fortawesome/fontawesome-free": "^7.2.0",
"@microsoft/signalr": "^10.0.0",
"bulma": "^1.0.4",
"curray": "^1.0.12",
"file-saver-es": "^2.0.5",
"filesize": "^11.0.13",
"filesize": "^11.0.17",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"zone.js": "^0.16.0"
"zone.js": "^0.16.1"
},
"devDependencies": {
"@angular-eslint/builder": "21.2.0",
"@angular-eslint/eslint-plugin": "21.2.0",
"@angular-eslint/eslint-plugin-template": "21.2.0",
"@angular-eslint/schematics": "21.2.0",
"@angular-eslint/template-parser": "21.2.0",
"@angular/build": "^21.1.4",
"@angular/cli": "^21.1.4",
"@angular/compiler-cli": "^21.1.4",
"@angular/language-service": "^21.1.4",
"@angular-eslint/builder": "21.3.1",
"@angular-eslint/eslint-plugin": "21.3.1",
"@angular-eslint/eslint-plugin-template": "21.3.1",
"@angular-eslint/schematics": "21.3.1",
"@angular-eslint/template-parser": "21.3.1",
"@angular/build": "^21.2.8",
"@angular/cli": "^21.2.8",
"@angular/compiler-cli": "^21.2.10",
"@angular/language-service": "^21.2.10",
"@types/file-saver": "^2.0.7",
"@types/file-saver-es": "^2.0.3",
"@types/node": "^25.2.3",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"eslint": "^9.39.2",
"prettier": "^3.8.1",
"@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"eslint": "^9.39.4",
"prettier": "^3.8.3",
"typescript": "5.9.3"
}
}

View file

@ -87,7 +87,7 @@
<div class="field" style="margin-top: 10px">
<div class="control">
<button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
@if (type === 'torrent') {
@if (type === "torrent") {
<span>Add Torrent</span>
} @else {
<span>Add NZB</span>
@ -109,9 +109,7 @@
<option [ngValue]="3">Synology DownloadStation</option>
</select>
</div>
<p class="help">
Select which downloader is used to download this from the debrid provider to your host.
</p>
<p class="help">Select which downloader is used to download this from the debrid provider to your host.</p>
</div>
<div class="field">
@ -123,8 +121,8 @@
</select>
</div>
<p class="help">
When a download is finished on the provider, perform this action. Use this setting if you only want
to add files to your debrid provider but not download them to the host.
When a download is finished on the provider, perform this action. Use this setting if you only want to add files
to your debrid provider but not download them to the host.
</p>
</div>
@ -213,15 +211,31 @@
<input class="input" type="number" [(ngModel)]="finishedActionDelay" />
</div>
<p class="help">
When a download is finished on the provider, perform this action. Use this setting if you only want
to add files to your debrid provider but not download them to the host.
When a download is finished on the provider, perform this action. Use this setting if you only want to add files
to your debrid provider but not download them to the host.
</p>
</div>
<div class="field">
<label class="label">Category</label>
<div class="control">
<input class="input" type="text" [(ngModel)]="category" />
<div class="control category-combo">
<input
class="input"
type="text"
[(ngModel)]="category"
(focus)="categoryDropdownOpen = true"
(input)="categoryDropdownOpen = true"
(blur)="onCategoryBlur()"
placeholder="Type or select a category"
autocomplete="off"
/>
@if (categoryDropdownOpen && filteredCategories.length > 0) {
<div class="category-dropdown">
@for (cat of filteredCategories; track $index) {
<a class="category-option" (mousedown)="selectCategory(cat)">{{ cat }}</a>
}
</div>
}
</div>
<p class="help">The category becomes a sub-folder in your main download path.</p>
</div>
@ -231,9 +245,7 @@
<div class="control">
<input class="input" type="number" step="1" [(ngModel)]="priority" />
</div>
<p class="help">
Set the priority where 1 is the highest. When empty it will be assigned the lowest priority.
</p>
<p class="help">Set the priority where 1 is the highest. When empty it will be assigned the lowest priority.</p>
</div>
</div>
</div>
@ -273,8 +285,8 @@
<input class="input" type="number" max="100000" min="0" step="1" [(ngModel)]="torrentLifetime" />
</div>
<p class="help">
The maximum lifetime of a download in minutes. When this time has passed, mark it as error. If the
download is completed, the lifetime setting will not apply. 0 to disable.
The maximum lifetime of a download in minutes. When this time has passed, mark it as error. If the download is
completed, the lifetime setting will not apply. 0 to disable.
</p>
</div>
</div>
@ -287,4 +299,3 @@
}
</div>
</div>

View file

@ -27,3 +27,29 @@
.separator:not(:empty)::after {
margin-left: 0.25em;
}
.category-combo {
position: relative;
}
.category-dropdown {
position: absolute;
z-index: 10;
width: 100%;
background: white;
border: 1px solid #dbdbdb;
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
}
.category-option {
display: block;
padding: 0.375rem 0.75rem;
color: #363636;
&:hover {
background: #f5f5f5;
}
}

View file

@ -1,11 +1,12 @@
import { Component, OnInit } from '@angular/core';
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { TorrentService } from 'src/app/torrent.service';
import { DownloadType, Torrent, TorrentFileAvailability } from '../models/torrent.model';
import { SettingsService } from '../settings.service';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { TorrentService } from '../torrent.service';
@Component({
selector: 'app-add-new-torrent',
@ -15,6 +16,12 @@ import { NgClass } from '@angular/common';
standalone: true,
})
export class AddNewTorrentComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private router = inject(Router);
private torrentService = inject(TorrentService);
private settingsService = inject(SettingsService);
private activatedRoute = inject(ActivatedRoute);
public type: 'torrent' | 'nzb' = 'torrent';
public fileName: string;
public magnetLink: string;
@ -24,7 +31,10 @@ export class AddNewTorrentComponent implements OnInit {
public provider: string;
public downloadClient: number;
public category: string;
private _category = '';
public categories: string[] = [];
public filteredCategories: string[] = [];
public categoryDropdownOpen = false;
public hostDownloadAction: number = 0;
public downloadAction: number = 0;
public finishedAction: number = 0;
@ -49,17 +59,19 @@ export class AddNewTorrentComponent implements OnInit {
public excludeRegexError: string;
public regexSelected: TorrentFileAvailability[];
private selectedFile: File;
private selectedFile: File | null = null;
constructor(
private router: Router,
private torrentService: TorrentService,
private settingsService: SettingsService,
private activatedRoute: ActivatedRoute,
) {}
public get category(): string {
return this._category;
}
public set category(value: string) {
this._category = value;
this.updateFilteredCategories();
}
ngOnInit(): void {
this.activatedRoute.queryParams.subscribe((params) => {
this.activatedRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
if (params['type'] === 'nzb') {
this.type = 'nzb';
} else if (params['type'] === 'torrent') {
@ -75,29 +87,65 @@ export class AddNewTorrentComponent implements OnInit {
this.type = 'nzb';
}
});
this.settingsService.get().subscribe((settings) => {
const providerSetting = settings.find((m) => m.key === 'Provider:Provider');
this.provider = providerSetting.enumValues[providerSetting.value as number];
this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.value as number;
this.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string;
this.hostDownloadAction = this.downloadAction = settings.find((m) => m.key === 'Gui:Default:HostDownloadAction')
?.value as number;
this.downloadAction =
settings.find((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.finishedAction = settings.find((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.finishedActionDelay = settings.find((m) => m.key == 'Gui:Default:FinishedActionDelay')?.value as number;
this.downloadMinSize = settings.find((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
this.includeRegex = settings.find((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
this.excludeRegex = settings.find((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
this.torrentRetryAttempts = settings.find((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
this.downloadRetryAttempts = settings.find((m) => m.key === 'Gui:Default:DownloadRetryAttempts')?.value as number;
this.torrentDeleteOnError = settings.find((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
this.torrentLifetime = settings.find((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
this.priority = settings.find((m) => m.key === 'Gui:Default:Priority')?.value as number;
this.settingsService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((settings) => {
const providerSetting = settings.find((m) => m.key === 'Provider:Provider');
this.provider = providerSetting.enumValues[providerSetting.value as number];
this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.value as number;
this.setFinishAction();
});
this.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string;
const categoriesSetting = settings.find((m) => m.key === 'General:Categories')?.value as string;
this.categories = (categoriesSetting ?? '')
.split(',')
.map((c) => c.trim())
.filter((c) => c.length > 0)
.filter((c, i, arr) => arr.findIndex((a) => a.toLowerCase() === c.toLowerCase()) === i);
const matchedCategory = this.categories.find((c) => c.toLowerCase() === (this.category ?? '').toLowerCase());
if (matchedCategory) {
this.category = matchedCategory;
} else {
this.updateFilteredCategories();
}
this.hostDownloadAction = this.downloadAction = settings.find((m) => m.key === 'Gui:Default:HostDownloadAction')
?.value as number;
this.downloadAction =
settings.find((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.finishedAction = settings.find((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.finishedActionDelay = settings.find((m) => m.key == 'Gui:Default:FinishedActionDelay')?.value as number;
this.downloadMinSize = settings.find((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
this.includeRegex = settings.find((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
this.excludeRegex = settings.find((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
this.torrentRetryAttempts = settings.find((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
this.downloadRetryAttempts = settings.find((m) => m.key === 'Gui:Default:DownloadRetryAttempts')
?.value as number;
this.torrentDeleteOnError = settings.find((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
this.torrentLifetime = settings.find((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
this.priority = settings.find((m) => m.key === 'Gui:Default:Priority')?.value as number;
this.setFinishAction();
});
}
private updateFilteredCategories(): void {
if (!this.category) {
this.filteredCategories = this.categories;
return;
}
const search = this.category.toLowerCase();
this.filteredCategories = this.categories.filter((value) => value.toLowerCase().includes(search));
}
public selectCategory(cat: string): void {
this.category = cat;
this.categoryDropdownOpen = false;
}
public onCategoryBlur(): void {
setTimeout(() => (this.categoryDropdownOpen = false), 150);
}
public setFinishAction() {
@ -119,7 +167,7 @@ export class AddNewTorrentComponent implements OnInit {
public pickFile(evt: Event): void {
const files = (evt.target as HTMLInputElement).files;
if (files.length === 0) {
if (files == null || files.length === 0) {
return;
}

View file

@ -2,9 +2,9 @@ import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
template: '<router-outlet></router-outlet>',
styles: [],
imports: [RouterOutlet],
selector: 'app-root',
template: '<router-outlet></router-outlet>',
styles: [],
imports: [RouterOutlet],
})
export class AppComponent {}

View file

@ -1,12 +1,12 @@
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private router: Router) {}
private router = inject(Router);
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req).pipe(

View file

@ -1,16 +1,14 @@
import { APP_BASE_HREF } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
@Injectable({
providedIn: 'root',
})
export class AuthService {
constructor(
private http: HttpClient,
@Inject(APP_BASE_HREF) private baseHref: string,
) {}
private http = inject(HttpClient);
private baseHref = inject(APP_BASE_HREF);
public isLoggedIn(): Observable<boolean> {
return this.http.get<boolean>(`${this.baseHref}Api/Authentication/IsLoggedIn`);

View file

@ -1,6 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'decodeURI', })
@Pipe({ name: 'decodeURI' })
export class DecodeURIPipe implements PipeTransform {
transform(input: any) {
return decodeURI(input);

View file

@ -1,10 +1,10 @@
import { Pipe, PipeTransform } from '@angular/core';
import { Pipe, PipeTransform, inject } from '@angular/core';
import { Download } from './models/download.model';
import { FileSizePipe } from './filesize.pipe';
@Pipe({ name: 'downloadStatus' })
export class DownloadStatusPipe implements PipeTransform {
constructor(private pipe: FileSizePipe) {}
private pipe = inject(FileSizePipe);
transform(value: Download): string {
if (!value) {

View file

@ -1,7 +1,7 @@
import { Pipe, PipeTransform } from '@angular/core';
import { filesize } from 'filesize';
@Pipe({ name: 'filesize', })
@Pipe({ name: 'filesize' })
export class FileSizePipe implements PipeTransform {
private static transformOne(value: number, options?: any): string {
return filesize(value, options).toString();

View file

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth.service';
import { FormsModule } from '@angular/forms';
@ -12,16 +12,14 @@ import { NgClass } from '@angular/common';
standalone: true,
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
public userName: string;
public password: string;
public error: string;
public loggingIn: boolean;
constructor(
private authService: AuthService,
private router: Router,
) {}
public setUserName(event: Event): void {
this.userName = (event.target as any).value;
}

View file

@ -44,9 +44,12 @@ export class Torrent {
public rdSpeed: number;
public rdSeeders: number;
public rdFiles: string;
public statusText?: string;
public filesCount?: number;
public downloadsCount?: number;
public files: TorrentFile[];
public downloads: Download[];
public files?: TorrentFile[];
public downloads?: Download[];
}
export class TorrentFile {

View file

@ -4,4 +4,4 @@
.notification {
margin-top: 52px;
}
}

View file

@ -1,9 +1,11 @@
import { Component, OnInit } from '@angular/core';
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink } from '@angular/router';
import { AuthService } from '../auth.service';
import { Profile } from '../models/profile.model';
import { SettingsService } from '../settings.service';
import { NgClass, DatePipe } from '@angular/common';
import { filter } from 'rxjs';
@Component({
selector: 'app-navbar',
@ -13,50 +15,60 @@ import { NgClass, DatePipe } from '@angular/common';
standalone: true,
})
export class NavbarComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private settingsService = inject(SettingsService);
private authService = inject(AuthService);
private router = inject(Router);
public showMobileMenu = false;
public profile: Profile;
public providerLink: string;
public version: string;
constructor(
private settingsService: SettingsService,
private authService: AuthService,
private router: Router,
) {
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
constructor() {
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(() => {
this.showMobileMenu = false;
}
});
});
}
ngOnInit(): void {
this.settingsService.getProfile().subscribe((result) => {
this.profile = result;
this.settingsService
.getProfile()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
this.profile = result;
switch (result.provider) {
case 'RealDebrid':
this.providerLink = 'https://real-debrid.com/?id=1348683';
break;
case 'AllDebrid':
this.providerLink = 'https://alldebrid.com/?uid=2v91l&lang=en';
break;
case 'Premiumize':
this.providerLink = 'https://www.premiumize.me/';
break;
case 'TorBox':
this.providerLink = 'https://torbox.app/';
break;
case 'DebridLink':
this.providerLink = 'https://debrid-link.com/';
break;
}
});
switch (result.provider) {
case 'RealDebrid':
this.providerLink = 'https://real-debrid.com/?id=1348683';
break;
case 'AllDebrid':
this.providerLink = 'https://alldebrid.com/?uid=2v91l&lang=en';
break;
case 'Premiumize':
this.providerLink = 'https://www.premiumize.me/';
break;
case 'TorBox':
this.providerLink = 'https://torbox.app/';
break;
case 'DebridLink':
this.providerLink = 'https://debrid-link.com/';
break;
}
});
this.settingsService.getVersion().subscribe((result) => {
this.version = result.version;
});
this.settingsService
.getVersion()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
this.version = result.version;
});
}
public logout(): void {

View file

@ -1,9 +1,9 @@
import { Pipe, PipeTransform, SecurityContext, VERSION } from '@angular/core';
import { Pipe, PipeTransform, SecurityContext, VERSION, inject } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({ name: 'nl2br', })
@Pipe({ name: 'nl2br' })
export class Nl2BrPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
private sanitizer = inject(DomSanitizer);
transform(value: string, sanitizeBeforehand?: boolean): string {
if (typeof value !== 'string') {

View file

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { AuthService } from '../auth.service';
import { FormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
@ -11,7 +11,7 @@ import { NgClass } from '@angular/common';
standalone: true,
})
export class ProfileComponent {
constructor(private authService: AuthService) {}
private authService = inject(AuthService);
public username: string;
public password: string;

View file

@ -1,5 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Profile } from './models/profile.model';
import { Setting } from './models/setting.model';
@ -10,10 +10,8 @@ import { Version } from './models/version.model';
providedIn: 'root',
})
export class SettingsService {
constructor(
private http: HttpClient,
@Inject(APP_BASE_HREF) private baseHref: string,
) {}
private http = inject(HttpClient);
private baseHref = inject(APP_BASE_HREF);
public get(): Observable<Setting[]> {
return this.http.get<Setting[]>(`${this.baseHref}Api/Settings`);

View file

@ -1,10 +1,10 @@
import { Component, OnInit } from '@angular/core';
import { SettingsService } from 'src/app/settings.service';
import { Component, OnInit, inject } from '@angular/core';
import { Setting } from '../models/setting.model';
import { NgClass, KeyValuePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Nl2BrPipe } from '../nl2br.pipe';
import { FileSizePipe } from '../filesize.pipe';
import { SettingsService } from '../settings.service';
@Component({
selector: 'app-settings',
@ -14,6 +14,8 @@ import { FileSizePipe } from '../filesize.pipe';
standalone: true,
})
export class SettingsComponent implements OnInit {
private settingsService = inject(SettingsService);
public activeTab = 0;
public tabs: Setting[] = [];
@ -35,8 +37,6 @@ export class SettingsComponent implements OnInit {
public canRegisterMagnetHandler = false;
constructor(private settingsService: SettingsService) {}
ngOnInit(): void {
this.reset();
this.canRegisterMagnetHandler = !!(window.isSecureContext && 'registerProtocolHandler' in navigator);

View file

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth.service';
import { NgClass } from '@angular/common';
@ -12,6 +12,9 @@ import { FormsModule } from '@angular/forms';
standalone: true,
})
export class SetupComponent {
private authService = inject(AuthService);
private router = inject(Router);
public userName: string;
public password: string;
public provider = 0;
@ -22,11 +25,6 @@ export class SetupComponent {
public step: number = 1;
constructor(
private authService: AuthService,
private router: Router,
) {}
public setup(): void {
this.error = null;
this.working = true;

View file

@ -1,20 +1,78 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'sort', })
export class SortPipe implements PipeTransform {
transform(array: any[], field: string, order: 'asc' | 'desc' = 'asc'): any[] {
if (!Array.isArray(array)) {
return [];
export type SortDirection = 'asc' | 'desc';
export function getSortFieldValue(item: unknown, field: string): unknown {
if (item == null || !field) {
return undefined;
}
return field.split('.').reduce<unknown>((value, key) => {
if (value == null || typeof value !== 'object') {
return undefined;
}
const sortedArray = array.sort((a, b) => {
if (a[field] < b[field]) {
return -1;
} else if (a[field] > b[field]) {
return 1;
} else {
return 0;
}
});
return order === 'asc' ? sortedArray : sortedArray.reverse();
return (value as Record<string, unknown>)[key];
}, item);
}
function compareSortValues(left: unknown, right: unknown): number {
if (left === right) {
return 0;
}
if (left == null) {
return 1;
}
if (right == null) {
return -1;
}
if (typeof left === 'string' && typeof right === 'string') {
return left.localeCompare(right, undefined, { sensitivity: 'base' });
}
if (left instanceof Date && right instanceof Date) {
return left.getTime() - right.getTime();
}
if (typeof left === 'boolean' && typeof right === 'boolean') {
return Number(left) - Number(right);
}
const comparableLeft = left as string | number | bigint;
const comparableRight = right as string | number | bigint;
if (comparableLeft < comparableRight) {
return -1;
}
if (comparableLeft > comparableRight) {
return 1;
}
return 0;
}
export function sortItems<T>(
array: readonly T[],
field: string,
order: SortDirection = 'asc',
accessor: (item: T, field: string) => unknown = getSortFieldValue,
): T[] {
if (!Array.isArray(array)) {
return [];
}
const direction = order === 'asc' ? 1 : -1;
return [...array].sort((left, right) => compareSortValues(accessor(left, field), accessor(right, field)) * direction);
}
@Pipe({ name: 'sort' })
export class SortPipe implements PipeTransform {
transform(array: unknown[], field: string, order: SortDirection = 'asc'): unknown[] {
return sortItems(array, field, order);
}
}

View file

@ -2,98 +2,131 @@ import { Pipe, PipeTransform } from '@angular/core';
import { RealDebridStatus, Torrent } from './models/torrent.model';
import { FileSizePipe } from './filesize.pipe';
@Pipe({ name: 'status' })
export class TorrentStatusPipe implements PipeTransform {
constructor(private pipe: FileSizePipe) {}
const fileSizePipe = new FileSizePipe();
transform(torrent: Torrent): string {
if (torrent.error) {
return torrent.error;
}
export function getTorrentStatus(torrent: Torrent): string {
if (torrent.error) {
return torrent.error;
}
if (torrent.downloads.length > 0) {
const allFinished = torrent.downloads.every((m) => m.completed != null);
const downloads = torrent.downloads ?? [];
if (allFinished) {
return 'Finished';
if (downloads.length > 0) {
let allFinished = true;
let downloadingCount = 0;
let downloadedCount = 0;
let downloadingBytesDone = 0;
let downloadingBytesTotal = 0;
let downloadingSpeed = 0;
let unpackingCount = 0;
let unpackedCount = 0;
let unpackingBytesDone = 0;
let unpackingBytesTotal = 0;
let queuedForUnpackingCount = 0;
let queuedForDownloadingCount = 0;
for (const download of downloads) {
if (download.completed == null) {
allFinished = false;
}
const downloading = torrent.downloads.filter((m) => m.downloadStarted && !m.downloadFinished && m.bytesDone > 0);
const downloaded = torrent.downloads.filter((m) => m.downloadFinished != null);
if (downloading.length > 0) {
const bytesDone = downloading.reduce((sum, m) => sum + m.bytesDone, 0);
const bytesTotal = downloading.reduce((sum, m) => sum + m.bytesTotal, 0);
const progress = (bytesDone / bytesTotal || 0) * 100;
const allSpeeds = downloading.reduce((sum, m) => sum + m.speed, 0);
const speed: string | string[] = this.pipe.transform(allSpeeds, 'filesize');
return `Downloading file ${downloading.length + downloaded.length}/${
torrent.downloads.length
} (${progress.toFixed(2)}% - ${speed}/s)`;
if (download.downloadFinished != null) {
downloadedCount += 1;
}
const unpacking = torrent.downloads.filter((m) => m.unpackingStarted && !m.unpackingFinished && m.bytesDone > 0);
const unpacked = torrent.downloads.filter((m) => m.unpackingFinished != null);
if (unpacking.length > 0) {
const bytesDone = unpacking.reduce((sum, m) => sum + m.bytesDone, 0);
const bytesTotal = unpacking.reduce((sum, m) => sum + m.bytesTotal, 0);
const progress = (bytesDone / bytesTotal || 0) * 100;
return `Extracting file ${unpacking.length + unpacked.length}/${torrent.downloads.length} (${progress.toFixed(
2,
)}%)`;
if (download.downloadStarted && !download.downloadFinished && download.bytesDone > 0) {
downloadingCount += 1;
downloadingBytesDone += download.bytesDone;
downloadingBytesTotal += download.bytesTotal;
downloadingSpeed += download.speed;
}
const queuedForUnpacking = torrent.downloads.filter((m) => m.unpackingQueued && !m.unpackingStarted);
if (queuedForUnpacking.length > 0) {
return `Queued for unpacking`;
if (download.unpackingFinished != null) {
unpackedCount += 1;
}
const queuedForDownload = torrent.downloads.filter((m) => !m.downloadStarted && !m.downloadFinished);
if (queuedForDownload.length > 0) {
return `Queued for downloading`;
if (download.unpackingStarted && !download.unpackingFinished && download.bytesDone > 0) {
unpackingCount += 1;
unpackingBytesDone += download.bytesDone;
unpackingBytesTotal += download.bytesTotal;
}
if (unpacked.length > 0) {
return `Files unpacked`;
if (download.unpackingQueued && !download.unpackingStarted) {
queuedForUnpackingCount += 1;
}
if (downloaded.length > 0) {
return `Files downloaded to host`;
if (!download.downloadStarted && !download.downloadFinished) {
queuedForDownloadingCount += 1;
}
}
if (torrent.completed) {
if (allFinished) {
return 'Finished';
}
switch (torrent.rdStatus) {
case RealDebridStatus.Queued:
return 'Not Yet Added to Provider';
case RealDebridStatus.Downloading:
if (torrent.rdSeeders < 1 && torrent.type !== 1) {
return `Torrent stalled`;
}
const speed = this.pipe.transform(torrent.rdSpeed, 'filesize');
return `Torrent downloading (${torrent.rdProgress}% - ${speed}/s)`;
case RealDebridStatus.Processing:
return `Torrent processing`;
case RealDebridStatus.WaitingForFileSelection:
return `Torrent waiting for file selection`;
case RealDebridStatus.Error:
return `Torrent error: ${torrent.rdStatusRaw}`;
case RealDebridStatus.Finished:
return `Torrent finished, waiting for download links`;
case RealDebridStatus.Uploading:
return `Torrent uploading`;
default:
return 'Unknown status';
if (downloadingCount > 0) {
const progress = ((downloadingBytesDone / downloadingBytesTotal) || 0) * 100;
const speed = fileSizePipe.transform(downloadingSpeed, 'filesize') as string;
return `Downloading file ${downloadingCount + downloadedCount}/${downloads.length} (${progress.toFixed(2)}% - ${speed}/s)`;
}
if (unpackingCount > 0) {
const progress = ((unpackingBytesDone / unpackingBytesTotal) || 0) * 100;
return `Extracting file ${unpackingCount + unpackedCount}/${downloads.length} (${progress.toFixed(2)}%)`;
}
if (queuedForUnpackingCount > 0) {
return 'Queued for unpacking';
}
if (queuedForDownloadingCount > 0) {
return 'Queued for downloading';
}
if (unpackedCount > 0) {
return 'Files unpacked';
}
if (downloadedCount > 0) {
return 'Files downloaded to host';
}
}
if (torrent.completed) {
return 'Finished';
}
const prefix = torrent.type === 1 ? 'NZB' : 'Torrent';
switch (torrent.rdStatus) {
case RealDebridStatus.Queued:
return 'Not Yet Added to Provider';
case RealDebridStatus.Downloading:
if (torrent.rdSeeders < 1 && torrent.type !== 1) {
return 'Torrent stalled';
}
return `${prefix} downloading (${torrent.rdProgress}% - ${fileSizePipe.transform(torrent.rdSpeed, 'filesize') as string}/s)`;
case RealDebridStatus.Processing:
return `${prefix} processing`;
case RealDebridStatus.WaitingForFileSelection:
return `${prefix} waiting for file selection`;
case RealDebridStatus.Error:
return `${prefix} error: ${torrent.rdStatusRaw}`;
case RealDebridStatus.Finished:
return `${prefix} finished, waiting for download links`;
case RealDebridStatus.Uploading:
return `${prefix} uploading`;
default:
return 'Unknown status';
}
}
@Pipe({ name: 'status' })
export class TorrentStatusPipe implements PipeTransform {
transform(torrent: Torrent): string {
return getTorrentStatus(torrent);
}
}

View file

@ -10,79 +10,115 @@
<br />
Available: {{ diskSpaceStatus.availableSpaceGB }} GB | Resume at: {{ diskSpaceStatus.thresholdGB * 2 }} GB
<br />
<small>Last check: {{ diskSpaceStatus.lastCheckTime | date: 'short' }}</small>
<small>Last check: {{ diskSpaceStatus.lastCheckTime | date: "short" }}</small>
</div>
}
@if (rateLimitStatus?.nextDequeueTime) {
<div class="notification is-warning">
<strong>Debrid provider rate limit reached</strong>
<br />
New torrents will not be added until {{ rateLimitStatus.nextDequeueTime | date: 'medium' }}
Processing paused until {{ rateLimitStatus.nextDequeueTime | date: "medium" }}
@if (rateLimitStatus.secondsRemaining > 0) {
({{ rateLimitStatus.secondsRemaining | number: "1.0-0" }} seconds remaining)
}
</div>
}
<div class="table-container">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>
<input
type="checkbox"
(click)="toggleDeleteSelectAll($event)"
[checked]="selectedTorrents.length > 0 && selectedTorrents.length === torrents.length"
/>
</th>
<th (click)="sort('rdName')">Name</th>
<th (click)="sort('category')">Category</th>
<th (click)="sort('priority')">Priority</th>
<th (click)="sort('rdSeeders')">Seeders</th>
<th (click)="sort('files.length')">Files</th>
<th (click)="sort('downloads.length')">Downloads</th>
<th (click)="sort('rdSize')">Size</th>
<th (click)="sort('added')">Requested</th>
<th (click)="sort('status')">Status</th>
</tr>
</thead>
<tbody>
@for (torrent of torrents | sort: sortProperty : sortDirection; track torrent.torrentId) {
@if (!isMobile) {
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<td>
<th>
<input
type="checkbox"
(click)="toggleDeleteSelectAll($event)"
[checked]="selectedTorrents.length > 0 && selectedTorrents.length === torrents.length"
/>
</th>
<th (click)="sort('rdName')">Name</th>
<th (click)="sort('category')">Category</th>
<th (click)="sort('priority')">Priority</th>
<th (click)="sort('rdSeeders')">Seeders</th>
<th (click)="sort('files.length')">Files</th>
<th (click)="sort('downloads.length')">Downloads</th>
<th (click)="sort('rdSize')">Size</th>
<th (click)="sort('added')">Requested</th>
<th (click)="sort('status')">Status</th>
</tr>
</thead>
<tbody>
@for (torrent of sortedTorrents; track torrent.torrentId) {
<tr>
<td>
<input type="checkbox" (click)="toggleSelect(torrent.torrentId)" [checked]="isSelected(torrent.torrentId)" />
</td>
<td (click)="openTorrent(torrent.torrentId)" class="break-all">
{{ torrent.rdName }}
</td>
<td>
{{ torrent.category }}
</td>
<td>
{{ torrent.priority }}
</td>
<td>
{{ torrent.rdSeeders }}
</td>
<td>
{{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }}
</td>
<td>
{{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}
</td>
<td>
{{ torrent.rdSize | filesize }}
</td>
<td>
{{ torrent.added | date: "medium" }}
</td>
<td>
{{ torrent.statusText ?? (torrent | status) }}
</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="mobile-cards">
@for (torrent of sortedTorrents; track torrent.torrentId) {
<div class="box mobile-card" [class.is-selected]="isSelected(torrent.torrentId)">
<div class="mobile-card-checkbox">
<input
type="checkbox"
(click)="toggleSelect(torrent.torrentId)"
[checked]="selectedTorrents.includes(torrent.torrentId)"
[checked]="isSelected(torrent.torrentId)"
/>
</td>
<td (click)="openTorrent(torrent.torrentId)" class="break-all">
{{ torrent.rdName }}
</td>
<td>
{{ torrent.category }}
</td>
<td>
{{ torrent.priority }}
</td>
<td>
{{ torrent.rdSeeders }}
</td>
<td>
{{ torrent.files.length | number }}
</td>
<td>
{{ torrent.downloads.length | number }}
</td>
<td>
{{ torrent.rdSize | filesize }}
</td>
<td>
{{ torrent.added | date: "medium" }}
</td>
<td>
{{ torrent | status }}
</td>
</tr>
</div>
<div class="mobile-card-body" (click)="openTorrent(torrent.torrentId)">
<p class="mobile-card-name">{{ torrent.rdName }}</p>
<div class="tags mb-2">
<span class="tag is-info is-light">{{ torrent.statusText ?? (torrent | status) }}</span>
@if (torrent.category) {
<span class="tag is-light">{{ torrent.category }}</span>
}
</div>
<div class="mobile-card-details">
<span class="mobile-card-detail"
><span class="detail-label">Size</span> {{ torrent.rdSize | filesize }}</span
>
<span class="mobile-card-detail"><span class="detail-label">Seeders</span> {{ torrent.rdSeeders }}</span>
<span class="mobile-card-detail"
><span class="detail-label">Files</span> {{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }}</span
>
<span class="mobile-card-detail"
><span class="detail-label">Downloads</span> {{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}</span
>
</div>
</div>
</div>
}
</tbody>
</table>
</div>
}
<div class="flex-container">
@if (torrents.length > 0) {

View file

@ -5,12 +5,74 @@ table {
word-break: break-all;
}
}
// Make table horizontally scrollable on small screens
@media screen and (max-width: 768px) {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
.mobile-cards {
display: block;
}
.mobile-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 0.75rem !important;
margin-bottom: 0.5rem !important;
cursor: pointer;
&.is-selected {
border-left: 3px solid hsl(217, 71%, 53%);
}
&:active {
background-color: hsl(0, 0%, 96%);
}
}
.mobile-card-checkbox {
flex-shrink: 0;
padding-top: 0.125rem;
input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
}
.mobile-card-body {
flex: 1;
min-width: 0;
}
.mobile-card-name {
font-weight: 600;
font-size: 0.9rem;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.375rem;
color: hsl(0, 0%, 21%);
}
.mobile-card-details {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
}
.mobile-card-detail {
font-size: 0.8rem;
color: hsl(0, 0%, 29%);
white-space: nowrap;
.detail-label {
color: hsl(0, 0%, 48%);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-right: 0.2em;
}
}
.flex-container {
@ -18,8 +80,17 @@ table {
flex: 1 1 0;
gap: 20px;
flex-direction: row;
margin-top: 1rem;
@media screen and (max-width: 1279px) {
flex-direction: column;
}
@media screen and (max-width: 768px) {
gap: 0.5rem;
.button {
width: 100%;
}
}
}

View file

@ -1,4 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { Torrent } from '../models/torrent.model';
import { DiskSpaceStatus } from '../models/disk-space-status.model';
@ -7,23 +8,29 @@ import { TorrentService } from '../torrent.service';
import { forkJoin, Observable } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { NgClass, DecimalPipe, DatePipe } from '@angular/common';
import { TorrentStatusPipe } from '../torrent-status.pipe';
import { SortPipe } from '../sort.pipe';
import { getTorrentStatus, TorrentStatusPipe } from '../torrent-status.pipe';
import { SortDirection, getSortFieldValue, sortItems } from '../sort.pipe';
import { FileSizePipe } from '../filesize.pipe';
@Component({
selector: 'app-torrent-table',
templateUrl: './torrent-table.component.html',
styleUrls: ['./torrent-table.component.scss'],
imports: [FormsModule, NgClass, DecimalPipe, DatePipe, TorrentStatusPipe, SortPipe, FileSizePipe],
imports: [FormsModule, NgClass, DecimalPipe, DatePipe, TorrentStatusPipe, FileSizePipe],
standalone: true,
})
export class TorrentTableComponent implements OnInit {
export class TorrentTableComponent implements OnInit, OnDestroy {
private destroyRef = inject(DestroyRef);
private router = inject(Router);
private torrentService = inject(TorrentService);
private selectedTorrentIds = new Set<string>();
public torrents: Torrent[] = [];
public sortedTorrents: Torrent[] = [];
public selectedTorrents: string[] = [];
public error: string;
public sortProperty = 'added';
public sortDirection: 'asc' | 'desc' = 'desc';
public sortDirection: SortDirection = 'desc';
public isDeleteModalActive: boolean;
public deleteError: string;
@ -53,12 +60,18 @@ export class TorrentTableComponent implements OnInit {
public diskSpaceStatus: DiskSpaceStatus | null = null;
public rateLimitStatus: RateLimitStatus | null = null;
constructor(
private router: Router,
private torrentService: TorrentService,
) {}
public isMobile = false;
private mobileQuery: MediaQueryList;
private mobileQueryListener: (e: MediaQueryListEvent) => void;
ngOnInit(): void {
this.mobileQuery = window.matchMedia('(max-width: 768px)');
this.isMobile = this.mobileQuery.matches;
this.mobileQueryListener = (e: MediaQueryListEvent) => {
this.isMobile = e.matches;
};
this.mobileQuery.addEventListener('change', this.mobileQueryListener);
// Load persisted sort settings (if any)
try {
const sp = localStorage.getItem('torrentTable.sortProperty');
@ -73,38 +86,47 @@ export class TorrentTableComponent implements OnInit {
// Ignore storage errors (e.g., disabled storage)
}
this.torrentService.getDiskSpaceStatus().subscribe({
next: (status) => {
this.diskSpaceStatus = status;
},
});
this.torrentService
.getDiskSpaceStatus()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (status) => {
this.diskSpaceStatus = status;
},
});
this.torrentService.diskSpaceStatus$.subscribe((status) => {
this.torrentService.diskSpaceStatus$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((status) => {
this.diskSpaceStatus = status;
});
this.torrentService.getRateLimitStatus().subscribe({
next: (status) => {
this.rateLimitStatus = status;
},
});
this.torrentService
.getRateLimitStatus()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (status) => {
this.rateLimitStatus = status;
},
});
this.torrentService.rateLimitStatus$.subscribe((status) => {
this.torrentService.rateLimitStatus$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((status) => {
this.rateLimitStatus = status;
});
this.torrentService.update$.subscribe((result) => {
this.torrents = result;
this.torrentService.update$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => {
this.setTorrents(result);
});
this.torrentService.getList().subscribe({
next: (result) => {
this.torrents = result;
},
error: (err) => {
this.error = err.error;
},
});
this.torrentService
.getList()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
this.setTorrents(result);
},
error: (err) => {
this.error = err.error;
},
});
}
public sort(property: string): void {
@ -122,30 +144,33 @@ export class TorrentTableComponent implements OnInit {
} catch (_) {
// Ignore storage errors
}
this.applySorting();
}
public openTorrent(torrentId: string): void {
this.router.navigate([`/torrent/${torrentId}`]);
}
public toggleDeleteSelectAll(event: any) {
this.selectedTorrents = [];
public toggleDeleteSelectAll(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
if (event.target.checked) {
this.torrents.map((torrent) => {
this.selectedTorrents.push(torrent.torrentId);
});
}
this.selectedTorrentIds = checked ? new Set(this.torrents.map((torrent) => torrent.torrentId)) : new Set<string>();
this.syncSelectedTorrents();
}
public toggleSelect(torrentId: string) {
const index = this.selectedTorrents.indexOf(torrentId);
if (index > -1) {
this.selectedTorrents.splice(index, 1);
if (this.selectedTorrentIds.has(torrentId)) {
this.selectedTorrentIds.delete(torrentId);
} else {
this.selectedTorrents.push(torrentId);
this.selectedTorrentIds.add(torrentId);
}
this.syncSelectedTorrents();
}
public isSelected(torrentId: string): boolean {
return this.selectedTorrentIds.has(torrentId);
}
public showDeleteModal(): void {
@ -175,7 +200,7 @@ export class TorrentTableComponent implements OnInit {
this.isDeleteModalActive = false;
this.deleting = false;
this.selectedTorrents = [];
this.clearSelectedTorrents();
},
error: (err) => {
this.deleteError = err.error;
@ -208,7 +233,7 @@ export class TorrentTableComponent implements OnInit {
this.isRetryModalActive = false;
this.retrying = false;
this.selectedTorrents = [];
this.clearSelectedTorrents();
},
error: (err) => {
this.retryError = err.error;
@ -220,7 +245,7 @@ export class TorrentTableComponent implements OnInit {
public changeSettingsModal(): void {
this.changeSettingsError = null;
const selectedTorrents = this.torrents.filter((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
const selectedTorrents = this.getSelectedTorrentModels();
this.updateSettingsDownloadClient = selectedTorrents.every(
(m, _, arr) => m.downloadClient === arr[0].downloadClient,
@ -267,7 +292,7 @@ export class TorrentTableComponent implements OnInit {
const calls: Observable<void>[] = [];
const selectedTorrents = this.torrents.filter((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
const selectedTorrents = this.getSelectedTorrentModels();
selectedTorrents.forEach((torrent) => {
if (this.updateSettingsDownloadClient != null) {
@ -283,7 +308,7 @@ export class TorrentTableComponent implements OnInit {
torrent.priority = this.updateSettingsPriority;
}
if (this.updateSettingsDownloadRetryAttempts != null) {
torrent.retryCount = this.updateSettingsDownloadRetryAttempts;
torrent.downloadRetryAttempts = this.updateSettingsDownloadRetryAttempts;
}
if (this.updateSettingsTorrentRetryAttempts != null) {
torrent.torrentRetryAttempts = this.updateSettingsTorrentRetryAttempts;
@ -303,7 +328,7 @@ export class TorrentTableComponent implements OnInit {
this.isChangeSettingsModalActive = false;
this.changingSettings = false;
this.selectedTorrents = [];
this.clearSelectedTorrents();
},
error: (err) => {
this.changeSettingsError = err.error;
@ -320,4 +345,56 @@ export class TorrentTableComponent implements OnInit {
updateDeleteSelectAll() {
this.deleteSelectAll = this.deleteData && this.deleteRdTorrent && this.deleteLocalFiles;
}
ngOnDestroy(): void {
this.mobileQuery?.removeEventListener('change', this.mobileQueryListener);
}
private setTorrents(torrents: Torrent[]): void {
this.torrents = torrents;
this.pruneSelectedTorrents();
this.applySorting();
}
private applySorting(): void {
this.sortedTorrents = sortItems(this.torrents, this.sortProperty, this.sortDirection, (torrent, field) => {
switch (field) {
case 'files.length':
return torrent.filesCount ?? torrent.files?.length ?? 0;
case 'downloads.length':
return torrent.downloadsCount ?? torrent.downloads?.length ?? 0;
case 'status':
return torrent.statusText ?? getTorrentStatus(torrent);
default:
return getSortFieldValue(torrent, field);
}
});
}
private getSelectedTorrentModels(): Torrent[] {
return this.torrents.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId));
}
private pruneSelectedTorrents(): void {
const torrentIds = new Set(this.torrents.map((torrent) => torrent.torrentId));
for (const torrentId of this.selectedTorrentIds) {
if (!torrentIds.has(torrentId)) {
this.selectedTorrentIds.delete(torrentId);
}
}
this.syncSelectedTorrents();
}
private clearSelectedTorrents(): void {
this.selectedTorrentIds.clear();
this.syncSelectedTorrents();
}
private syncSelectedTorrents(): void {
this.selectedTorrents = this.torrents
.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId))
.map((torrent) => torrent.torrentId);
}
}

View file

@ -1,5 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { Observable, Subject } from 'rxjs';
import { Torrent, TorrentFileAvailability } from './models/torrent.model';
@ -11,16 +11,16 @@ import { APP_BASE_HREF } from '@angular/common';
providedIn: 'root',
})
export class TorrentService {
private http = inject(HttpClient);
private baseHref = inject(APP_BASE_HREF);
public update$: Subject<Torrent[]> = new Subject();
public diskSpaceStatus$: Subject<DiskSpaceStatus> = new Subject();
public rateLimitStatus$: Subject<RateLimitStatus> = new Subject();
private connection: signalR.HubConnection;
constructor(
private http: HttpClient,
@Inject(APP_BASE_HREF) private baseHref: string,
) {
constructor() {
this.connect();
}

View file

@ -23,7 +23,7 @@
@if (activeTab === 0) {
<div class="flex-container">
<div style="flex: 1 1 0">
<div class="field is-grouped">
<div class="field is-grouped is-grouped-multiline action-buttons">
<div class="control">
<button class="button is-danger" (click)="showDeleteModal()">Delete Torrent</button>
</div>
@ -44,7 +44,7 @@
</div>
<div class="field">
<label class="label">Hash</label>
{{ torrent.hash }}
<span class="break-all">{{ torrent.hash }}</span>
</div>
<div class="field">
<label class="label">Priority</label>
@ -240,72 +240,75 @@
@if (activeTab === 1) {
<div>
<div class="field">
<table class="table is-fullwidth">
<thead>
<tr>
<th>ID</th>
<th>Path</th>
<th>Size</th>
<th>Selected</th>
</tr>
</thead>
<tbody>
@for (file of torrent.files; track file.id) {
<div class="table-container">
<table class="table is-fullwidth">
<thead>
<tr>
<td>
{{ file.id }}
</td>
<td>
{{ file.path }}
</td>
<td>
{{ file.bytes | filesize }}
</td>
<td>
@if (file.selected) {
<i class="fas fa-check" style="color: green"></i>
} @else {
<i class="fas fa-times" style="color: red"></i>
}
</td>
<th>ID</th>
<th>Path</th>
<th>Size</th>
<th>Selected</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@for (file of torrent.files; track file.id) {
<tr>
<td>
{{ file.id }}
</td>
<td class="break-all">
{{ file.path }}
</td>
<td>
{{ file.bytes | filesize }}
</td>
<td>
@if (file.selected) {
<i class="fas fa-check" style="color: green"></i>
} @else {
<i class="fas fa-times" style="color: red"></i>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
@if (activeTab === 2) {
<div>
<div class="field">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th style="width: 35px"></th>
<th>Link</th>
<th>Size</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (download of torrent.downloads; track download.downloadId) {
@let expanded = downloadExpanded[download.downloadId];
<tr (click)="downloadExpanded[download.downloadId] = !expanded">
<td style="width: 35px">
@if (!expanded) {
<i class="fas fa-caret-right"></i>
} @else {
<i class="fas fa-caret-down"></i>
}
</td>
<td>
@if (download.link) {
{{ download.link | decodeURI }}
}
@if (!download.link) {
{{ download.path }}
}
</td>
<div class="table-container">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th style="width: 35px"></th>
<th>Link</th>
<th>Size</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (download of torrent.downloads; track download.downloadId) {
@let expanded = downloadExpanded[download.downloadId];
<tr (click)="downloadExpanded[download.downloadId] = !expanded">
<td style="width: 35px">
@if (!expanded) {
<i class="fas fa-caret-right"></i>
} @else {
<i class="fas fa-caret-down"></i>
}
</td>
<td class="break-all">
@if (download.link) {
{{ download.link | decodeURI }}
}
@if (!download.link) {
{{ download.path }}
}
</td>
<td>
{{ download.bytesTotal | filesize }}
</td>
@ -335,10 +338,10 @@
<div class="field">
<label class="label">Real-Debrid Unrestricted Link</label>
@if (download.link) {
<a href="{{ download.link }}" target="_blank"> {{ download.link | decodeURI }}</a>
<a class="break-all" href="{{ download.link }}" target="_blank"> {{ download.link | decodeURI }}</a>
}
</div>
<div class="field">
<div class="field break-all">
<label class="label">Real-Debrid Link</label>
{{ download.path }}
</div>
@ -431,8 +434,9 @@
</tr>
}
}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
}

View file

@ -8,6 +8,10 @@ table {
}
}
.break-all {
word-break: break-all;
}
.flex-container {
display: flex;
flex: 1 1 0;
@ -18,3 +22,24 @@ table {
flex-direction: column;
}
}
@media screen and (max-width: 768px) {
.action-buttons {
.control {
width: 100%;
.button {
width: 100%;
}
}
}
.tabs ul {
flex-wrap: wrap;
li {
flex-grow: 1;
text-align: center;
}
}
}

View file

@ -1,4 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { saveAs } from 'file-saver-es';
import { Torrent } from '../models/torrent.model';
@ -10,6 +11,7 @@ import { TorrentStatusPipe } from '../torrent-status.pipe';
import { DownloadStatusPipe } from '../download-status.pipe';
import { DecodeURIPipe } from '../decode-uri.pipe';
import { FileSizePipe } from '../filesize.pipe';
import { EMPTY, distinctUntilChanged, map, switchMap, tap, catchError } from 'rxjs';
@Component({
selector: 'app-torrent',
@ -28,6 +30,12 @@ import { FileSizePipe } from '../filesize.pipe';
standalone: true,
})
export class TorrentComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private activatedRoute = inject(ActivatedRoute);
private router = inject(Router);
private torrentService = inject(TorrentService);
private currentTorrentId: string | null = null;
public torrent: Torrent;
public activeTab: number = 0;
@ -66,48 +74,66 @@ export class TorrentComponent implements OnInit {
public updating: boolean;
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private torrentService: TorrentService,
) {}
ngOnInit(): void {
this.activatedRoute.params.subscribe((params) => {
const torrentId = params['id'];
this.torrentService.get(torrentId).subscribe({
next: (torrent) => {
this.torrent = torrent;
this.torrentService.update$.subscribe((result) => {
this.update(result);
});
},
error: () => this.router.navigate(['/']),
this.activatedRoute.params
.pipe(
map((params) => params['id'] as string),
distinctUntilChanged(),
tap((torrentId) => {
this.currentTorrentId = torrentId;
}),
switchMap((torrentId) =>
this.torrentService.get(torrentId).pipe(
catchError(() => {
this.router.navigate(['/']);
return EMPTY;
}),
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((torrent) => {
this.torrent = torrent;
this.currentTorrentId = torrent.torrentId;
});
this.torrentService.update$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => {
this.update(result);
});
}
public update(torrents: Torrent[]): void {
const updatedTorrent = torrents.find((m) => m.torrentId === this.torrent.torrentId);
const torrentId = this.currentTorrentId ?? this.torrent?.torrentId;
if (!torrentId) {
return;
}
const updatedTorrent = torrents.find((m) => m.torrentId === torrentId);
if (updatedTorrent == null) {
return;
}
this.torrent = updatedTorrent;
const currentTorrent = this.torrent;
const hasIncomingFiles = Array.isArray(updatedTorrent.files) && updatedTorrent.files.length > 0;
this.torrent = {
...currentTorrent,
...updatedTorrent,
fileOrMagnet: updatedTorrent.fileOrMagnet ?? currentTorrent?.fileOrMagnet,
files: hasIncomingFiles ? updatedTorrent.files : currentTorrent?.files ?? [],
downloads: updatedTorrent.downloads ?? currentTorrent?.downloads ?? [],
};
}
public download(): void {
const byteArray = new Uint8Array(
window
.atob(this.torrent.fileOrMagnet)
.split('')
.map(function (c) {
return c.charCodeAt(0);
}),
);
const binaryString = window.atob(this.torrent.fileOrMagnet);
const byteArray = new Uint8Array(binaryString.length);
for (let index = 0; index < binaryString.length; index += 1) {
byteArray[index] = binaryString.charCodeAt(index);
}
const blob = new Blob([byteArray], { type: 'application/x-bittorrent' });
saveAs(blob, `${this.torrent.rdName}.torrent`);

View file

@ -1,7 +1,6 @@
import { enableProdMode, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { environment } from './environments/environment';
import { FileSizePipe } from './app/filesize.pipe';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
@ -18,12 +17,12 @@ if (environment.production) {
}
bootstrapApplication(AppComponent, {
providers: [
provideZoneChangeDetection(),importProvidersFrom(BrowserModule, AppRoutingModule, FormsModule, ClipboardModule),
FileSizePipe,
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: APP_BASE_HREF, useValue: (window as any)['_app_base'] || '/' },
provideHttpClient(withInterceptorsFromDi()),
]
})
.catch((err) => console.error(err));
providers: [
provideZoneChangeDetection(),
importProvidersFrom(BrowserModule, AppRoutingModule, FormsModule, ClipboardModule),
FileSizePipe,
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: APP_BASE_HREF, useValue: (window as any)['_app_base'] || '/' },
provideHttpClient(withInterceptorsFromDi()),
],
}).catch((err) => console.error(err));

View file

@ -16,6 +16,14 @@ public class DataContext(DbContextOptions options) : IdentityDbContext(options)
{
base.OnModelCreating(builder);
builder.Entity<Download>()
.HasIndex(m => new
{
m.TorrentId,
m.Path
})
.IsUnique();
var cascadeFKs = builder.Model.GetEntityTypes()
.SelectMany(t => t.GetForeignKeys())
.Where(fk => !fk.IsOwnership && fk.DeleteBehavior == DeleteBehavior.Cascade);

View file

@ -0,0 +1,9 @@
namespace RdtClient.Data.Data;
public enum DownloadAddResult
{
Added,
AlreadyExists,
TorrentMissing,
InvalidInput
}

View file

@ -1,10 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using RdtClient.Data.Models.Data;
using Download = RdtClient.Data.Models.Data.Download;
namespace RdtClient.Data.Data;
public class DownloadData(DataContext dataContext)
public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger = null)
{
public async Task<List<Download>> GetForTorrent(Guid torrentId)
{
@ -30,8 +32,29 @@ public class DownloadData(DataContext dataContext)
.FirstOrDefaultAsync(m => m.TorrentId == torrentId && m.Path == path);
}
public async Task<Download> Add(Guid torrentId, DownloadInfo downloadInfo)
public async Task<DownloadAddResult> TryAddForTorrent(Guid torrentId, DownloadInfo downloadInfo)
{
if (String.IsNullOrWhiteSpace(downloadInfo.RestrictedLink))
{
logger?.LogDebug("Skipped download creation because the restricted link was blank. TorrentId: {torrentId}", torrentId);
return DownloadAddResult.InvalidInput;
}
if (!await dataContext.Torrents.AsNoTracking().AnyAsync(m => m.TorrentId == torrentId))
{
logger?.LogDebug("Skipped download creation because the torrent no longer exists. TorrentId: {torrentId}, Path: {path}", torrentId, downloadInfo.RestrictedLink);
return DownloadAddResult.TorrentMissing;
}
if (await dataContext.Downloads.AsNoTracking().AnyAsync(m => m.TorrentId == torrentId && m.Path == downloadInfo.RestrictedLink))
{
logger?.LogDebug("Skipped download creation because it already exists. TorrentId: {torrentId}, Path: {path}", torrentId, downloadInfo.RestrictedLink);
return DownloadAddResult.AlreadyExists;
}
var download = new Download
{
DownloadId = Guid.NewGuid(),
@ -45,9 +68,36 @@ public class DownloadData(DataContext dataContext)
await dataContext.Downloads.AddAsync(download);
await dataContext.SaveChangesAsync();
try
{
await dataContext.SaveChangesAsync();
return download;
return DownloadAddResult.Added;
}
// These shouldn't be possible any longer, but added for safety and until confirmed.
catch (DbUpdateException ex)
{
dataContext.Entry(download).State = EntityState.Detached;
if (IsDuplicateDownloadViolation(ex))
{
logger?.LogDebug("Skipped download creation after a concurrent duplicate insert. TorrentId: {torrentId}, Path: {path}", torrentId, downloadInfo.RestrictedLink);
return DownloadAddResult.AlreadyExists;
}
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);
return DownloadAddResult.TorrentMissing;
}
throw;
}
}
public async Task UpdateUnrestrictedLink(Guid downloadId, String unrestrictedLink)
@ -226,7 +276,7 @@ public class DownloadData(DataContext dataContext)
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)
@ -235,7 +285,7 @@ public class DownloadData(DataContext dataContext)
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;
@ -246,4 +296,20 @@ public class DownloadData(DataContext dataContext)
await dataContext.SaveChangesAsync();
}
private static Boolean IsDuplicateDownloadViolation(DbUpdateException exception)
{
var sqliteException = exception.InnerException as SqliteException;
return sqliteException?.SqliteExtendedErrorCode == 2067
|| sqliteException?.Message.Contains("UNIQUE constraint failed: Downloads.TorrentId, Downloads.Path", StringComparison.Ordinal) == true;
}
private static Boolean IsForeignKeyViolation(DbUpdateException exception)
{
var sqliteException = exception.InnerException as SqliteException;
return sqliteException?.SqliteExtendedErrorCode == 787
|| sqliteException?.Message.Contains("FOREIGN KEY constraint failed", StringComparison.Ordinal) == true;
}
}

View file

@ -1,10 +1,11 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
namespace RdtClient.Data.Data;
public class TorrentData(DataContext dataContext) : ITorrentData
public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger = null) : ITorrentData
{
public async Task<IList<Torrent>> Get()
{
@ -12,6 +13,7 @@ public class TorrentData(DataContext dataContext) : ITorrentData
.AsNoTracking()
.AsSplitQuery()
.Include(m => m.Downloads)
.OrderBy(m => m.Priority ?? 9999)
.ToListAsync();
return torrents.OrderBy(m => m.Priority ?? 9999)
@ -286,15 +288,23 @@ public class TorrentData(DataContext dataContext) : ITorrentData
public async Task Delete(Guid torrentId)
{
var dbTorrent = await dataContext.Torrents.FirstOrDefaultAsync(m => m.TorrentId == torrentId);
await using var transaction = await dataContext.Database.BeginTransactionAsync();
if (dbTorrent == null)
await dataContext.Downloads
.Where(m => m.TorrentId == torrentId)
.ExecuteDeleteAsync();
var deletedTorrents = await dataContext.Torrents
.Where(m => m.TorrentId == torrentId)
.ExecuteDeleteAsync();
await transaction.CommitAsync();
if (deletedTorrents == 0)
{
logger?.LogDebug("Skipped torrent graph deletion because the torrent was not found. TorrentId: {torrentId}", torrentId);
return;
}
dataContext.Torrents.Remove(dbTorrent);
await dataContext.SaveChangesAsync();
}
}

View file

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using RdtClient.Data.Data;
#nullable disable
namespace RdtClient.Data.Migrations;
[DbContext(typeof(DataContext))]
[Migration("20260423031719_Downloads_Add_TorrentIdPath_Unique")]
public partial class Downloads_Add_TorrentIdPath_Unique : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Orphaned downloads from previous failures.
migrationBuilder.Sql("""
DELETE FROM Downloads
WHERE rowid NOT IN (
SELECT MIN(rowid)
FROM Downloads
GROUP BY TorrentId, Path
);
""");
migrationBuilder.DropIndex(
name: "IX_Downloads_TorrentId",
table: "Downloads");
// Prevent accidental duplicates, provides idempotency at the DB level for downloads
migrationBuilder.CreateIndex(
name: "IX_Downloads_TorrentId_Path",
table: "Downloads",
columns: ["TorrentId", "Path"],
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Downloads_TorrentId_Path",
table: "Downloads");
migrationBuilder.CreateIndex(
name: "IX_Downloads_TorrentId",
table: "Downloads",
column: "TorrentId");
}
}

View file

@ -15,7 +15,7 @@ namespace RdtClient.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
@ -263,7 +263,8 @@ namespace RdtClient.Data.Migrations
b.HasKey("DownloadId");
b.HasIndex("TorrentId");
b.HasIndex("TorrentId", "Path")
.IsUnique();
b.ToTable("Downloads");
});

View file

@ -8,6 +8,10 @@ namespace RdtClient.Data.Models.Data;
public class Torrent
{
private String? _rdFiles;
private IList<DebridClientFile> _filesCache = [];
private Boolean _filesCacheInitialized;
[Key]
public Guid TorrentId { get; set; }
@ -59,26 +63,52 @@ public class Torrent
public DateTimeOffset? RdEnded { get; set; }
public Int64? RdSpeed { get; set; }
public Int64? RdSeeders { get; set; }
public String? RdFiles { get; set; }
public String? RdFiles
{
get => _rdFiles;
set
{
if (_rdFiles == value)
{
return;
}
_rdFiles = value;
_filesCache = [];
_filesCacheInitialized = false;
}
}
[NotMapped]
public IList<DebridClientFile> Files
{
get
{
if (_filesCacheInitialized)
{
return _filesCache;
}
_filesCacheInitialized = true;
if (String.IsNullOrWhiteSpace(RdFiles))
{
return [];
_filesCache = [];
return _filesCache;
}
try
{
return JsonSerializer.Deserialize<List<DebridClientFile>>(RdFiles) ?? [];
_filesCache = JsonSerializer.Deserialize<List<DebridClientFile>>(RdFiles) ?? [];
}
catch
{
return [];
_filesCache = [];
}
return _filesCache;
}
}

View file

@ -21,6 +21,7 @@ public class TorrentDto
public DateTimeOffset? FilesSelected { get; set; }
public DateTimeOffset? Completed { get; set; }
public DownloadType Type { get; set; }
public String? FileOrMagnet { get; set; }
public Boolean IsFile { get; set; }
public Int32? Priority { get; set; }
public Int32 RetryCount { get; set; }
@ -41,6 +42,9 @@ public class TorrentDto
public DateTimeOffset? RdEnded { get; set; }
public Int64? RdSpeed { get; set; }
public Int64? RdSeeders { get; set; }
public String StatusText { get; set; } = null!;
public Int32 FilesCount { get; set; }
public Int32 DownloadsCount { get; set; }
public IList<DebridClientFile> Files { get; set; } = [];
public IList<DownloadDto> Downloads { get; set; } = [];
}

View file

@ -4,6 +4,21 @@ namespace RdtClient.Data.Models.QBittorrent;
public class TorrentFileItem
{
[JsonPropertyName("index")]
public Int32 Index { get; set; }
[JsonPropertyName("name")]
public String? Name { get; set; }
[JsonPropertyName("size")]
public Int64 Size { get; set; }
[JsonPropertyName("progress")]
public Single Progress { get; set; }
[JsonPropertyName("priority")]
public Int32 Priority { get; set; }
[JsonPropertyName("is_seed")]
public Boolean IsSeed { get; set; }
}

View file

@ -13,6 +13,9 @@ public class TorrentProperties
[JsonPropertyName("completion_date")]
public Int64? CompletionDate { get; set; }
[JsonPropertyName("is_private")]
public Boolean IsPrivate { get; set; }
[JsonPropertyName("created_by")]
public String? CreatedBy { get; set; }

View file

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.QBittorrent;
public class Tracker
{
[JsonPropertyName("url")]
public required String Url { get; set; }
[JsonPropertyName("status")]
public required String Status { get; set; }
[JsonPropertyName("num_peers")]
public required Int64 NumPeers { get; set; }
[JsonPropertyName("msg")]
public required String Msg { get; set; }
}

View file

@ -8,11 +8,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3">
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View file

@ -2,9 +2,7 @@ using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Data;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Services;
@ -17,6 +15,7 @@ public class WatchFolderCheckerTests : IDisposable
private readonly Mock<IServiceProvider> _serviceProviderMock;
private readonly Mock<IServiceScope> _serviceScopeMock;
private readonly String _testPath;
private readonly TestSettings _settings;
private readonly Mock<Torrents> _torrentsServiceMock;
public WatchFolderCheckerTests()
@ -25,7 +24,18 @@ public class WatchFolderCheckerTests : IDisposable
_serviceProviderMock = new();
_serviceScopeMock = new();
_scopeServiceProviderMock = new();
_torrentsServiceMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
_settings = new(new()
{
Watch = new()
{
Interval = 0,
Default = new()
},
DownloadClient = new()
});
_torrentsServiceMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_serviceProviderMock
.Setup(x => x.GetService(typeof(IServiceScopeFactory)))
@ -42,10 +52,8 @@ public class WatchFolderCheckerTests : IDisposable
_testPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(_testPath);
// Reset Settings and Startup
SetStartupReady(true);
ResetSettings();
Settings.Get.Watch.Path = _testPath;
_settings.Current.Watch.Path = _testPath;
}
public void Dispose()
@ -62,22 +70,6 @@ public class WatchFolderCheckerTests : IDisposable
property?.SetValue(null, ready);
}
private static void ResetSettings()
{
var settings = new DbSettings
{
Watch = new()
{
Interval = 0,
Default = new()
},
DownloadClient = new()
};
var property = typeof(SettingData).GetProperty("Get", BindingFlags.Public | BindingFlags.Static);
property?.SetValue(null, settings);
}
private static void ResetPrevCheck(WatchFolderChecker checker)
{
var field = typeof(WatchFolderChecker).GetField("_prevCheck", BindingFlags.NonPublic | BindingFlags.Instance);
@ -92,7 +84,7 @@ public class WatchFolderCheckerTests : IDisposable
var content = "torrent content"u8.ToArray();
await File.WriteAllBytesAsync(filePath, content);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object, _settings);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
@ -131,7 +123,7 @@ public class WatchFolderCheckerTests : IDisposable
var content = "magnet:?xt=urn:btih:123";
await File.WriteAllTextAsync(filePath, content);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object, _settings);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
@ -170,7 +162,7 @@ public class WatchFolderCheckerTests : IDisposable
var content = "nzb content"u8.ToArray();
await File.WriteAllBytesAsync(filePath, content);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object, _settings);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
@ -209,7 +201,7 @@ public class WatchFolderCheckerTests : IDisposable
var filePath = Path.Combine(_testPath, "test.txt");
await File.WriteAllTextAsync(filePath, "ignore me");
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object, _settings);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();

View file

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

View file

@ -181,16 +181,7 @@ public class DownloadHelperTest
FileName = "file.txt"
};
String fileRelativePath;
if (OSHelper.IsLinux)
{
fileRelativePath = "inside/lots/of/subdirectories/file.txt";
}
else
{
fileRelativePath = @"inside\lots\of\subdirectories\file.txt";
}
var fileRelativePath = Path.Combine("inside", "lots", "of", "subdirectories", "file.txt");
IList<DebridClientFile> files =
[
@ -212,7 +203,7 @@ public class DownloadHelperTest
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem);
// Assert
var expectedPath = Path.Combine("/data/downloads", torrent.RdName, fileRelativePath);
var expectedPath = Path.Combine("/data/downloads", torrent.RdName, "inside", "lots", "of", "subdirectories", "file.txt");
Assert.Equal(expectedPath, path);
}
@ -226,16 +217,7 @@ public class DownloadHelperTest
FileName = "file.txt"
};
String fileRelativePath;
if (OSHelper.IsLinux)
{
fileRelativePath = "inside/lots/of/subdirectories/file.txt";
}
else
{
fileRelativePath = @"inside\lots\of\subdirectories\file.txt";
}
var fileRelativePath = Path.Combine("inside", "lots", "of", "subdirectories", "file.txt");
IList<DebridClientFile> files =
[
@ -255,7 +237,78 @@ public class DownloadHelperTest
var path = DownloadHelper.GetDownloadPath(torrent, download);
// Assert
var expectedPath = Path.Combine(torrent.RdName, fileRelativePath);
var expectedPath = Path.Combine(torrent.RdName, "inside", "lots", "of", "subdirectories", "file.txt");
Assert.Equal(expectedPath, path);
}
[Fact]
public void GetDownloadPath_WithPath_WhenFilePathStartsWithTorrentName_StripsPrefix()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var fileRelativePath = Path.Combine("Torrent Name", "Saison 1", "file.txt");
IList<DebridClientFile> files =
[
new()
{
Path = fileRelativePath
}
];
var torrent = new Torrent
{
RdName = "Torrent Name",
RdFiles = JsonSerializer.Serialize(files)
};
var fileSystem = new MockFileSystem();
// Act
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem);
// Assert
// The torrent name prefix in the file path should not duplicate the torrent name in the base dir
var expectedPath = Path.Combine("/data/downloads", "Torrent Name", "Saison 1", "file.txt");
Assert.Equal(expectedPath, path);
}
[Fact]
public void GetDownloadPath_WithoutPath_WhenFilePathStartsWithTorrentName_StripsPrefix()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var fileRelativePath = Path.Combine("Torrent Name", "Saison 1", "file.txt");
IList<DebridClientFile> files =
[
new()
{
Path = fileRelativePath
}
];
var torrent = new Torrent
{
RdName = "Torrent Name",
RdFiles = JsonSerializer.Serialize(files)
};
// Act
var path = DownloadHelper.GetDownloadPath(torrent, download);
// Assert
var expectedPath = Path.Combine("Torrent Name", "Saison 1", "file.txt");
Assert.Equal(expectedPath, path);
}

View file

@ -1,8 +0,0 @@
using System.Runtime.InteropServices;
namespace RdtClient.Service.Test.Helpers;
public static class OSHelper
{
public static Boolean IsLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
}

View file

@ -0,0 +1,68 @@
using RdtClient.Service.Helpers;
namespace RdtClient.Service.Test.Helpers;
public class RateLimitCoordinatorTest
{
[Fact]
public void UpdateCooldown_SetsCooldown()
{
// Arrange
var coordinator = new RateLimitCoordinator();
var key = "test.host";
var delay = TimeSpan.FromMinutes(5);
// Act
coordinator.UpdateCooldown(key, delay);
var remaining = coordinator.GetRemainingCooldown(key);
// Assert
Assert.True(remaining > TimeSpan.FromMinutes(4) && remaining <= TimeSpan.FromMinutes(5));
}
[Fact]
public void GetRemainingCooldown_ReturnsZero_WhenNoCooldown()
{
// Arrange
var coordinator = new RateLimitCoordinator();
var key = "test.host";
// Act
var remaining = coordinator.GetRemainingCooldown(key);
// Assert
Assert.Equal(TimeSpan.Zero, remaining);
}
[Fact]
public void GetMaxNextAllowedAt_ReturnsMax()
{
// Arrange
var coordinator = new RateLimitCoordinator();
var now = DateTimeOffset.UtcNow;
coordinator.UpdateCooldown("host1", TimeSpan.FromMinutes(10));
coordinator.UpdateCooldown("host2", TimeSpan.FromMinutes(20));
// Act
var maxNext = coordinator.GetMaxNextAllowedAt();
// Assert
Assert.NotNull(maxNext);
Assert.True(maxNext > now.AddMinutes(19));
Assert.True(maxNext < now.AddMinutes(21));
}
[Fact]
public void GetMaxNextAllowedAt_ReturnsNull_WhenAllExpired()
{
// Arrange
var coordinator = new RateLimitCoordinator();
coordinator.UpdateCooldown("host1", TimeSpan.FromSeconds(-10));
// Act
var maxNext = coordinator.GetMaxNextAllowedAt();
// Assert
Assert.Null(maxNext);
}
}

View file

@ -1,4 +1,5 @@
using System.Net;
using Moq;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers;
@ -6,11 +7,13 @@ namespace RdtClient.Service.Test.Helpers;
public class RateLimitHandlerTest
{
private readonly Mock<IRateLimitCoordinator> _coordinatorMock = new();
[Fact]
public async Task SendAsync_ThrowsRateLimitException_On429WithRetryAfter()
{
// Arrange
var handler = new RateLimitHandler
var handler = new RateLimitHandler(_coordinatorMock.Object)
{
InnerHandler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, 3600)
};
@ -20,14 +23,16 @@ public class RateLimitHandlerTest
// Act & Assert
var ex = await Assert.ThrowsAsync<RateLimitException>(() => client.GetAsync("http://example.com"));
Assert.Equal(TimeSpan.FromSeconds(3600), ex.RetryAfter);
Assert.Equal("TorBox rate limit exceeded", ex.Message);
Assert.Contains("rate limit exceeded", ex.Message);
_coordinatorMock.Verify(m => m.UpdateCooldown("example.com", TimeSpan.FromSeconds(3600)), Times.Once);
}
[Fact]
public async Task SendAsync_ThrowsRateLimitException_On429WithoutRetryAfter()
{
// Arrange
var handler = new RateLimitHandler
var handler = new RateLimitHandler(_coordinatorMock.Object)
{
InnerHandler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, null)
};
@ -39,6 +44,40 @@ public class RateLimitHandlerTest
Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter);
}
[Fact]
public async Task SendAsync_ThrowsRateLimitException_WhenCooldownActive()
{
// Arrange
_coordinatorMock.Setup(m => m.GetRemainingCooldown("example.com")).Returns(TimeSpan.FromMinutes(5));
var handler = new RateLimitHandler(_coordinatorMock.Object)
{
InnerHandler = new MockHttpMessageHandler(HttpStatusCode.OK, null)
};
var client = new HttpClient(handler);
// Act & Assert
var ex = await Assert.ThrowsAsync<RateLimitException>(() => client.GetAsync("http://example.com"));
Assert.Equal(TimeSpan.FromMinutes(5), ex.RetryAfter);
Assert.Contains("cooldown active", ex.Message);
}
[Fact]
public async Task SendAsync_DoesNotCatchTimeoutException()
{
// Arrange
var handler = new RateLimitHandler(_coordinatorMock.Object)
{
InnerHandler = new MockExceptionHandler(new TimeoutException())
};
var client = new HttpClient(handler);
// Act & Assert
await Assert.ThrowsAsync<TimeoutException>(() => client.GetAsync("http://example.com"));
}
private class MockHttpMessageHandler(HttpStatusCode statusCode, Int32? retryAfterSeconds) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
@ -53,4 +92,12 @@ public class RateLimitHandlerTest
return Task.FromResult(response);
}
}
private class MockExceptionHandler(Exception ex) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
throw ex;
}
}
}

View file

@ -5,23 +5,24 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<AssemblyName>RdtClient.Service.Test</AssemblyName>
<RootNamespace>RdtClient.Service.Test</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AllDebrid.NET" Version="1.0.18" />
<PackageReference Include="coverlet.collector" Version="8.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.0.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.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
<PackageReference Include="TorBox.NET" Version="1.6.2" />
<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="2.1.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
@ -37,10 +38,4 @@
<ProjectReference Include="..\RdtClient.Service\RdtClient.Service.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="TorBoxNET">
<HintPath>..\..\..\torbox-net\TorBoxNET\bin\Release\netstandard2.0\TorBoxNET.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,186 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using RdtClient.Data.Data;
using RdtClient.Data.Models.Data;
namespace RdtClient.Service.Test.Regression;
public class TorrentDownloadRaceTests : IAsyncLifetime
{
private readonly String _databasePath = Path.Combine(Path.GetTempPath(), $"rdt-client-race-{Guid.NewGuid():N}.sqlite");
[Fact]
public async Task Add_WhenTorrentIsDeletedAfterItWasRead_DoesNotThrowAndDoesNotInsertDownload()
{
var torrentId = await SeedTorrentAsync();
await using (var readerContext = CreateContext())
{
var torrentData = new TorrentData(readerContext);
var torrent = await torrentData.GetById(torrentId);
Assert.NotNull(torrent);
}
await using (var deleteContext = CreateContext())
{
var torrentData = new TorrentData(deleteContext);
await torrentData.Delete(torrentId);
}
DownloadAddResult result;
await using (var addContext = CreateContext())
{
var downloadData = new DownloadData(addContext);
result = await downloadData.TryAddForTorrent(torrentId, CreateDownloadInfo("race-a"));
}
Assert.Equal(DownloadAddResult.TorrentMissing, result);
await using var verifyContext = CreateContext();
Assert.False(await verifyContext.Torrents.AnyAsync(m => m.TorrentId == torrentId));
Assert.False(await verifyContext.Downloads.AnyAsync());
}
[Fact]
public async Task Delete_WhenDownloadIsInsertedBetweenChildAndParentDelete_DoesNotThrowAndRemovesTorrentGraph()
{
var torrentId = await SeedTorrentAsync();
await using (var deleteChildrenContext = CreateContext())
{
var downloadData = new DownloadData(deleteChildrenContext);
await downloadData.DeleteForTorrent(torrentId);
}
await using (var addContext = CreateContext())
{
var downloadData = new DownloadData(addContext);
var result = await downloadData.TryAddForTorrent(torrentId, CreateDownloadInfo("race-b"));
Assert.Equal(DownloadAddResult.Added, result);
}
Exception? exception;
await using (var deleteParentContext = CreateContext())
{
var torrentData = new TorrentData(deleteParentContext);
exception = await Record.ExceptionAsync(() => torrentData.Delete(torrentId));
}
Assert.Null(exception);
await using var verifyContext = CreateContext();
Assert.False(await verifyContext.Torrents.AnyAsync(m => m.TorrentId == torrentId));
Assert.False(await verifyContext.Downloads.AnyAsync(m => m.TorrentId == torrentId));
}
[Fact]
public async Task TryAddForTorrent_WhenDownloadAlreadyExists_DoesNotInsertDuplicate()
{
var torrentId = await SeedTorrentAsync();
await using (var firstContext = CreateContext())
{
var downloadData = new DownloadData(firstContext);
var result = await downloadData.TryAddForTorrent(torrentId, CreateDownloadInfo("race-c"));
Assert.Equal(DownloadAddResult.Added, result);
}
await using (var secondContext = CreateContext())
{
var downloadData = new DownloadData(secondContext);
var result = await downloadData.TryAddForTorrent(torrentId, CreateDownloadInfo("race-c"));
Assert.Equal(DownloadAddResult.AlreadyExists, result);
}
await using var verifyContext = CreateContext();
Assert.Equal(1, await verifyContext.Downloads.CountAsync(m => m.TorrentId == torrentId));
}
[Fact]
public async Task Delete_WhenTorrentWasAlreadyDeleted_DoesNotThrow()
{
var torrentId = await SeedTorrentAsync();
await using (var firstDeleteContext = CreateContext())
{
var torrentData = new TorrentData(firstDeleteContext);
await torrentData.Delete(torrentId);
}
Exception? exception;
await using (var secondDeleteContext = CreateContext())
{
var torrentData = new TorrentData(secondDeleteContext);
exception = await Record.ExceptionAsync(() => torrentData.Delete(torrentId));
}
Assert.Null(exception);
}
public async Task InitializeAsync()
{
await using var context = CreateContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
}
public Task DisposeAsync()
{
SqliteConnection.ClearAllPools();
if (File.Exists(_databasePath))
{
File.Delete(_databasePath);
}
return Task.CompletedTask;
}
private DataContext CreateContext()
{
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = _databasePath,
ForeignKeys = true
}.ToString();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connectionString)
.Options;
return new(options);
}
private async Task<Guid> SeedTorrentAsync()
{
var torrentId = Guid.NewGuid();
await using var context = CreateContext();
context.Torrents.Add(new()
{
TorrentId = torrentId,
Hash = Guid.NewGuid().ToString("N"),
Added = DateTimeOffset.UtcNow
});
await context.SaveChangesAsync();
return torrentId;
}
private static DownloadInfo CreateDownloadInfo(String suffix)
{
return new()
{
FileName = $"download-{suffix}.bin",
RestrictedLink = $"https://example.invalid/{suffix}"
};
}
}

View file

@ -3,6 +3,7 @@ using System.Text;
using Moq;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using TorrentRunnerState = RdtClient.Service.Services.TorrentRunnerState;
using TorrentsService = RdtClient.Service.Services.Torrents;
namespace RdtClient.Service.Test.Services;
@ -28,7 +29,9 @@ public class NzbTorrentsTest
null!,
null!,
null!,
null!);
null!,
new TestSettings(),
new TorrentRunnerState());
}
[Fact]

View file

@ -1,7 +1,9 @@
using Microsoft.Extensions.Logging;
using Moq;
using System.Text.Json;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient;
using RdtClient.Service.Services;
namespace RdtClient.Service.Test.Services;
@ -11,15 +13,19 @@ public class QBittorrentTest
private readonly Mock<Authentication> _authenticationMock;
private readonly Mock<ILogger<QBittorrent>> _loggerMock;
private readonly QBittorrent _qBittorrent;
private readonly TestSettings _settings;
private readonly TorrentRunnerState _runnerState;
private readonly Mock<Torrents> _torrentsMock;
public QBittorrentTest()
{
_loggerMock = new();
_torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
_settings = new();
_runnerState = new();
_torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, _runnerState);
_authenticationMock = new(null!, null!, null!);
_qBittorrent = new(_loggerMock.Object, null!, _authenticationMock.Object, _torrentsMock.Object, null!);
_qBittorrent = new(_loggerMock.Object, _settings, _authenticationMock.Object, _torrentsMock.Object, null!, _runnerState);
}
[Fact]
@ -136,4 +142,268 @@ public class QBittorrentTest
// Current behavior is (1.0 + 0.8) / 2 = 0.9
Assert.Equal(0.9f, result[0].Progress);
}
[Fact]
public async Task TorrentFileContents_ShouldReturnAllFiles_WithIndexesAndPriority()
{
// Arrange
var torrent = new Torrent
{
Hash = "hash1",
Type = DownloadType.Torrent,
RdFiles = JsonSerializer.Serialize(new List<DebridClientFile>
{
new()
{
Id = 1,
Path = "good.mkv",
Bytes = 1000,
Selected = true
},
new()
{
Id = 2,
Path = "dangerous.exe",
Bytes = 10,
Selected = false
}
})
};
_torrentsMock.Setup(m => m.GetByHash("hash1")).ReturnsAsync(torrent);
// Act
var result = await _qBittorrent.TorrentFileContents("hash1");
// 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);
});
}
[Fact]
public async Task TorrentProperties_ShouldExposePublicTorrentFlag()
{
// Arrange
var torrent = new Torrent
{
Hash = "hash1",
Type = DownloadType.Torrent,
Added = DateTimeOffset.UtcNow
};
_torrentsMock.Setup(m => m.GetByHash("hash1")).ReturnsAsync(torrent);
// Act
var result = await _qBittorrent.TorrentProperties("hash1");
// Assert
Assert.NotNull(result);
Assert.False(result!.IsPrivate);
}
[Fact]
public async Task TorrentInfo_ShouldUseSelectedTopLevelFileName_WhenRootFileAddsOnlyTheExtension()
{
// Arrange
var previousMappedPath = _settings.Current.DownloadClient.MappedPath;
_settings.Current.DownloadClient.MappedPath = "/data/downloads";
try
{
var torrentRootName = "Sample.Show.S01E01.1080p.WEB-DL-GROUP";
var selectedFileName = $"{torrentRootName}.mkv";
var torrent = new Torrent
{
Hash = "hash1",
Category = "tv",
Completed = DateTimeOffset.UtcNow,
RdName = torrentRootName,
RdFiles = JsonSerializer.Serialize(new List<DebridClientFile>
{
new()
{
Id = 1,
Path = $"/{selectedFileName}",
Bytes = 1000,
Selected = true
},
new()
{
Id = 2,
Path = $"/{torrentRootName}.nfo",
Bytes = 10,
Selected = false
}
}),
Type = DownloadType.Torrent
};
_torrentsMock.Setup(m => m.Get())
.ReturnsAsync(new List<Torrent>
{
torrent
});
// Act
var result = await _qBittorrent.TorrentInfo();
// Assert
Assert.Single(result);
Assert.Equal(Path.Combine("/data/downloads", "tv", selectedFileName) + Path.DirectorySeparatorChar, result[0].ContentPath);
Assert.Equal(torrentRootName, result[0].Name);
}
finally
{
_settings.Current.DownloadClient.MappedPath = previousMappedPath;
}
}
[Fact]
public async Task TorrentInfo_ShouldUseExistingCompletedContentRoot_WhenSingleFileDirectoryDiffersFromRdName()
{
var previousMappedPath = _settings.Current.DownloadClient.MappedPath;
var mappedPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
_settings.Current.DownloadClient.MappedPath = mappedPath;
try
{
var category = "tv";
var selectedFileName = "Sample.Show.S01E02.1080p.WEB-DL-GROUP.mkv";
var contentRoot = Path.Combine(mappedPath, category, selectedFileName);
Directory.CreateDirectory(contentRoot);
await File.WriteAllTextAsync(Path.Combine(contentRoot, selectedFileName), "test");
var torrent = new Torrent
{
Hash = "hash1",
Category = category,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>
{
new()
{
DownloadId = Guid.NewGuid(),
FileName = selectedFileName,
Link = "https://fake.url/Sample.Show.S01E02.1080p.WEB-DL-GROUP.mkv",
Completed = DateTimeOffset.UtcNow
}
},
RdName = "tracker.example - Sample.Show.S01E02.1080p.WEB-DL-GROUP",
RdFiles = JsonSerializer.Serialize(new List<DebridClientFile>
{
new()
{
Id = 1,
Path = $"/{selectedFileName}",
Bytes = 1000,
Selected = true
}
}),
Type = DownloadType.Torrent
};
_torrentsMock.Setup(m => m.Get())
.ReturnsAsync(new List<Torrent>
{
torrent
});
var result = await _qBittorrent.TorrentInfo();
Assert.Single(result);
Assert.Equal(contentRoot + Path.DirectorySeparatorChar, result[0].ContentPath);
}
finally
{
_settings.Current.DownloadClient.MappedPath = previousMappedPath;
if (Directory.Exists(mappedPath))
{
Directory.Delete(mappedPath, true);
}
}
}
[Fact]
public async Task TorrentInfo_ShouldUseExistingCompletedContentRoot_WhenSelectedFileIsNestedUnderDifferentRoot()
{
var previousMappedPath = _settings.Current.DownloadClient.MappedPath;
var mappedPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
_settings.Current.DownloadClient.MappedPath = mappedPath;
try
{
var category = "tv";
var rootDirectoryName = "tracker.example - Sample.Show.S01E03.1080p.x265-GROUP.mkv";
var nestedDirectoryName = "Sample.Show.S01E03.1080p.x265-GROUP";
var fileName = "Sample.Show.S01E03.1080p.x265-GROUP.mkv";
var contentRoot = Path.Combine(mappedPath, category, rootDirectoryName);
var nestedDirectory = Path.Combine(contentRoot, nestedDirectoryName);
Directory.CreateDirectory(nestedDirectory);
await File.WriteAllTextAsync(Path.Combine(nestedDirectory, fileName), "test");
var torrent = new Torrent
{
Hash = "hash1",
Category = category,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>
{
new()
{
DownloadId = Guid.NewGuid(),
FileName = fileName,
Link = "https://fake.url/Sample.Show.S01E03.1080p.x265-GROUP.mkv",
Completed = DateTimeOffset.UtcNow
}
},
RdName = "tracker.example - Sample.Show.S01E03.1080p.x265-GROUP",
RdFiles = JsonSerializer.Serialize(new List<DebridClientFile>
{
new()
{
Id = 1,
Path = $"/{nestedDirectoryName}/{fileName}",
Bytes = 1000,
Selected = true
}
}),
Type = DownloadType.Torrent
};
_torrentsMock.Setup(m => m.Get())
.ReturnsAsync(new List<Torrent>
{
torrent
});
var result = await _qBittorrent.TorrentInfo();
Assert.Single(result);
Assert.Equal(contentRoot + Path.DirectorySeparatorChar, result[0].ContentPath);
}
finally
{
_settings.Current.DownloadClient.MappedPath = previousMappedPath;
if (Directory.Exists(mappedPath))
{
Directory.Delete(mappedPath, true);
}
}
}
}

View file

@ -1,6 +1,5 @@
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Data;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
@ -16,11 +15,13 @@ public class SabnzbdTest
};
private readonly Mock<ILogger<Sabnzbd>> _loggerMock = new();
private readonly TestSettings _settings;
private readonly Mock<Torrents> _torrentsMock;
public SabnzbdTest()
{
_torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
_settings = new();
_torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(new List<Torrent>());
}
@ -50,7 +51,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
_torrentsMock.Setup(t => t.GetDownloadStats(It.IsAny<Guid>())).Returns((0, 1000, 500));
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
@ -90,7 +91,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
@ -134,7 +135,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
@ -186,7 +187,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
@ -224,7 +225,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
@ -260,7 +261,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetHistory();
@ -275,7 +276,7 @@ public class SabnzbdTest
{
// Arrange
var savePath = @"C:\Downloads";
SettingData.Get.DownloadClient.MappedPath = savePath;
_settings.Current.DownloadClient.MappedPath = savePath;
var torrentList = new List<Torrent>
{
@ -292,7 +293,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetHistory();
@ -308,7 +309,7 @@ public class SabnzbdTest
{
// Arrange
var savePath = @"C:\Downloads";
SettingData.Get.DownloadClient.MappedPath = savePath;
_settings.Current.DownloadClient.MappedPath = savePath;
var torrentList = new List<Torrent>
{
@ -325,7 +326,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetHistory();
@ -355,7 +356,7 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetHistory();
@ -365,11 +366,69 @@ 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()
{
// Arrange
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = sabnzbd.GetConfig();
@ -399,9 +458,9 @@ public class SabnzbdTest
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
SettingData.Get.General.Categories = "TV, Music, *";
_settings.Current.General.Categories = "TV, Music, *";
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = sabnzbd.GetCategories();

View file

@ -0,0 +1,225 @@
using System.Net;
using System.Text;
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services;
using RdtClient.Service.Services.DebridClients;
namespace RdtClient.Service.Test.Services.TorrentClients;
public class PremiumizeDebridClientTest
{
private readonly Mock<IDownloadableFileFilter> _fileFilterMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<ILogger<PremiumizeDebridClient>> _loggerMock;
private readonly TestSettings _settings;
public PremiumizeDebridClientTest()
{
_loggerMock = new();
_httpClientFactoryMock = new();
_fileFilterMock = new();
_settings = new();
_settings.Current.Provider.ApiKey = "test-api-key";
}
[Fact]
public async Task AddNzbLink_UsesPremiumizeNetTransferCreate_ReturnsTransferId()
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"transfer-id","name":"test.nzb"}"""));
var client = CreateClient(handler);
// Act
var result = await client.AddNzbLink("http://example.com/test.nzb");
// Assert
Assert.Equal("transfer-id", result);
Assert.Equal(HttpMethod.Post, handler.Request!.Method);
Assert.Equal("https://www.premiumize.me/api/transfer/create?apikey=test-api-key", handler.Request.RequestUri!.ToString());
Assert.Null(handler.Request.Headers.Authorization);
Assert.Equal("application/x-www-form-urlencoded", handler.Request.Content!.Headers.ContentType!.MediaType);
Assert.Equal("src=http%3A%2F%2Fexample.com%2Ftest.nzb&folder_id=", handler.RequestBody);
}
[Fact]
public async Task AddNzbFile_SubmitsMultipartTransferCreateRequest_ReturnsTransferId()
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"file-transfer-id","name":"test.nzb"}"""));
var client = CreateClient(handler);
// Act
var result = await client.AddNzbFile(Encoding.UTF8.GetBytes("nzb body"), "test.nzb");
// Assert
Assert.Equal("file-transfer-id", result);
Assert.Equal(HttpMethod.Post, handler.Request!.Method);
Assert.Equal("https://www.premiumize.me/api/transfer/create", handler.Request.RequestUri!.ToString());
Assert.Equal("Bearer", handler.Request.Headers.Authorization!.Scheme);
Assert.Equal("test-api-key", handler.Request.Headers.Authorization.Parameter);
Assert.Equal("multipart/form-data", handler.Request.Content!.Headers.ContentType!.MediaType);
Assert.Contains("Content-Disposition: form-data", handler.RequestBody);
Assert.Contains("src", handler.RequestBody);
Assert.Contains("test.nzb", handler.RequestBody);
Assert.Contains("nzb body", handler.RequestBody);
}
[Fact]
public async Task AddNzbFile_WhenNameIsMissing_UsesDefaultFilename()
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"file-transfer-id","name":"upload.nzb"}"""));
var client = CreateClient(handler);
// Act
await client.AddNzbFile(Encoding.UTF8.GetBytes("nzb body"), null);
// Assert
Assert.Contains("upload.nzb", handler.RequestBody);
}
[Fact]
public async Task AddNzbFile_WhenNameHasNoNzbExtension_AppendsNzbExtension()
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"file-transfer-id","name":"release-title.nzb"}"""));
var client = CreateClient(handler);
// Act
await client.AddNzbFile(Encoding.UTF8.GetBytes("nzb body"), "release-title");
// Assert
Assert.Contains("release-title.nzb", handler.RequestBody);
}
[Fact]
public async Task AddNzbLink_WhenSuccessResponseHasNoId_Throws()
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","name":"test.nzb"}"""));
var client = CreateClient(handler);
// Act
var ex = await Assert.ThrowsAsync<Exception>(() => client.AddNzbLink("http://example.com/test.nzb"));
// Assert
Assert.Contains("Unable to add NZB link", ex.Message);
}
[Fact]
public async Task AddNzbLink_WhenPremiumizeReturnsRateLimitError_ThrowsRateLimitException()
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"error","code":"slow_down","message":"rate limit exceeded"}"""));
var client = CreateClient(handler);
// Act
var ex = await Assert.ThrowsAsync<RateLimitException>(() => client.AddNzbLink("http://example.com/test.nzb"));
// Assert
Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter);
}
[Theory]
[InlineData("You've made too many API requests too quickly.")]
[InlineData("Your fair-use points, booster points, or active-job count is exhausted.")]
[InlineData("This account's usage limit for this service has been reached.")]
[InlineData("The target service is unreachable right now. Retry after a delay.")]
public async Task AddNzbLink_WhenPremiumizeNetReturnsDocumentedRetryMessage_ThrowsRateLimitException(String message)
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse($$"""{"status":"error","message":"{{message}}","code":"rate_limit_reached"}"""));
var client = CreateClient(handler);
// Act
var ex = await Assert.ThrowsAsync<RateLimitException>(() => client.AddNzbLink("http://example.com/test.nzb"));
// Assert
Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter);
}
[Theory]
[InlineData("rate_limit_reached")]
[InlineData("account_limit_reached")]
[InlineData("service_limit_reached")]
[InlineData("service_down")]
[InlineData("semi_permanent_error")]
public async Task AddNzbFile_WhenPremiumizeReturnsDocumentedRetryCode_ThrowsRateLimitException(String code)
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse($$"""{"status":"error","code":"{{code}}","message":"retry later"}"""));
var client = CreateClient(handler);
// Act
var ex = await Assert.ThrowsAsync<RateLimitException>(() => client.AddNzbFile(Encoding.UTF8.GetBytes("nzb body"), "test.nzb"));
// Assert
Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter);
}
[Fact]
public async Task AddTorrentMagnet_UsesPremiumizeNetTransferCreate_ReturnsTransferId()
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"magnet-transfer-id","name":"test"}"""));
var client = CreateClient(handler);
// Act
var result = await client.AddTorrentMagnet("magnet:?xt=urn:btih:test");
// Assert
Assert.Equal("magnet-transfer-id", result);
Assert.Equal("https://www.premiumize.me/api/transfer/create?apikey=test-api-key", handler.Request!.RequestUri!.ToString());
Assert.Equal("src=magnet%3A%3Fxt%3Durn%3Abtih%3Atest&folder_id=", handler.RequestBody);
}
[Fact]
public async Task AddTorrentFile_UsesPremiumizeNetFileUpload_ReturnsTransferId()
{
// Arrange
var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"torrent-transfer-id","name":"test"}"""));
var client = CreateClient(handler);
// Act
var result = await client.AddTorrentFile(Encoding.UTF8.GetBytes("torrent body"));
// Assert
Assert.Equal("torrent-transfer-id", result);
Assert.Equal("https://www.premiumize.me/api/transfer/create?apikey=test-api-key", handler.Request!.RequestUri!.ToString());
Assert.Contains("name=file", handler.RequestBody);
Assert.Contains("filename=1.torrent", handler.RequestBody);
Assert.Contains("application/x-bittorrent", handler.RequestBody);
Assert.Contains("torrent body", handler.RequestBody);
}
private PremiumizeDebridClient CreateClient(RecordingHttpMessageHandler handler)
{
_httpClientFactoryMock.Setup(m => m.CreateClient(It.IsAny<String>())).Returns(new HttpClient(handler));
return new(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _settings);
}
private static HttpResponseMessage JsonResponse(String json, HttpStatusCode statusCode = HttpStatusCode.OK)
{
return new(statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private class RecordingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
public HttpRequestMessage? Request { get; private set; }
public String? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
RequestBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return handler(request);
}
}
}

View file

@ -6,6 +6,7 @@ using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers;
using RdtClient.Service.Services;
using RdtClient.Service.Services.DebridClients;
using TorBoxNET;
@ -16,21 +17,25 @@ namespace RdtClient.Service.Test.Services.TorrentClients;
public class TorBoxDebridClientTest
{
private readonly Mock<IRateLimitCoordinator> _coordinatorMock;
private readonly Mock<IDownloadableFileFilter> _fileFilterMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<ILogger<TorBoxDebridClient>> _loggerMock;
private readonly TestSettings _settings;
public TorBoxDebridClientTest()
{
_loggerMock = new();
_httpClientFactoryMock = new();
_fileFilterMock = new();
_coordinatorMock = new();
_settings = new();
var httpClient = new HttpClient();
_httpClientFactoryMock.Setup(m => m.CreateClient(It.IsAny<String>())).Returns(httpClient);
Settings.Get.Provider.ApiKey = "test-api-key";
Settings.Get.Provider.Timeout = 100;
_settings.Current.Provider.ApiKey = "test-api-key";
_settings.Current.Provider.Timeout = 100;
}
[Fact]
@ -41,6 +46,7 @@ public class TorBoxDebridClientTest
{
new()
{
Id = 12345,
Hash = "hash1",
Name = "torrent1",
Size = 1000,
@ -61,7 +67,7 @@ public class TorBoxDebridClientTest
}
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
clientMock.Protected().Setup<Task<IEnumerable<TorrentInfoResult>?>>("GetCurrentTorrents").ReturnsAsync(torrents);
clientMock.Protected().Setup<Task<IEnumerable<TorrentInfoResult>?>>("GetQueuedTorrents").ReturnsAsync(new List<TorrentInfoResult>());
clientMock.Protected().Setup<Task<IEnumerable<UsenetInfoResult>?>>("GetCurrentUsenet").ReturnsAsync(nzbs);
@ -73,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);
@ -111,7 +117,7 @@ public class TorBoxDebridClientTest
}
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
clientMock.Protected().Setup<Task<Response<List<AvailableTorrent?>>>>("GetTorrentAvailability", hash).ReturnsAsync(availability);
// Act
@ -154,7 +160,7 @@ public class TorBoxDebridClientTest
}
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
clientMock.Protected().Setup<Task<Response<List<AvailableTorrent?>>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability);
clientMock.Protected().Setup<Task<Response<List<AvailableUsenet?>>>>("GetUsenetAvailability", hash).ReturnsAsync(usenetAvailability);
@ -183,7 +189,7 @@ public class TorBoxDebridClientTest
Data = new()
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
clientMock.Protected().Setup<Task<Response<List<AvailableTorrent?>>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability);
clientMock.Protected().Setup<Task<Response<List<AvailableUsenet?>>>>("GetUsenetAvailability", hash).ReturnsAsync(usenetAvailability);
@ -195,27 +201,27 @@ 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
};
var torrentsApiMock = new Mock<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
// Act
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]
@ -229,11 +235,11 @@ public class TorBoxDebridClientTest
};
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
// Act
await clientMock.Object.Delete(torrent);
@ -255,11 +261,11 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/d/123/456";
var torrentsApiMock = new Mock<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String>
@ -288,11 +294,11 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/d/123/456";
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String>
@ -320,7 +326,7 @@ public class TorBoxDebridClientTest
var name = "test-nzb";
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings)
{
CallBase = true
};
@ -328,7 +334,7 @@ public class TorBoxDebridClientTest
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny<Int32>(), name, It.IsAny<String?>(), It.IsAny<Boolean>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<UsenetAddResult>
@ -370,25 +376,20 @@ public class TorBoxDebridClientTest
var torrent = new Torrent
{
Hash = "test-hash",
RdId = "12345",
RdFiles = JsonConvert.SerializeObject(files)
};
var torrentsApiMock = new Mock<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new TorrentInfoResult
{
Id = 12345
});
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
_fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).Returns(true);
Settings.Get.Provider.PreferZippedDownloads = false;
_settings.Current.Provider.PreferZippedDownloads = false;
// Act
var result = await clientMock.Object.GetDownloadInfos(torrent);
@ -417,25 +418,20 @@ public class TorBoxDebridClientTest
var torrent = new Torrent
{
Hash = "test-hash",
RdId = "12345",
RdName = "TestTorrent",
RdFiles = JsonConvert.SerializeObject(files),
DownloadClient = DownloadClient.Aria2c
};
Settings.Get.Provider.PreferZippedDownloads = true;
_settings.Current.Provider.PreferZippedDownloads = true;
var torrentsApiMock = new Mock<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new TorrentInfoResult
{
Id = 12345
});
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
_fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).Returns(true);
@ -461,11 +457,11 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/fakedl/12345/6789";
var torrentsApiMock = new Mock<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 6789, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String>
@ -493,11 +489,11 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/fakedl/12345/zip";
var torrentsApiMock = new Mock<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 0, true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String>
@ -535,13 +531,13 @@ public class TorBoxDebridClientTest
};
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.GetCurrentAsync(true, It.IsAny<CancellationToken>()))
usenetApiMock.Setup(m => m.GetCurrentAsync(true, 1000, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<UsenetInfoResult>
{
new()
@ -553,7 +549,7 @@ public class TorBoxDebridClientTest
_fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).Returns(true);
Settings.Get.Provider.PreferZippedDownloads = false;
_settings.Current.Provider.PreferZippedDownloads = false;
// Act
var result = await clientMock.Object.GetDownloadInfos(torrent);
@ -576,11 +572,11 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/fakedl/98765/4321";
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String>
@ -604,7 +600,7 @@ public class TorBoxDebridClientTest
var torrentsApiMock = new Mock<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings)
{
CallBase = true
};
@ -613,7 +609,7 @@ public class TorBoxDebridClientTest
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User>
@ -644,7 +640,7 @@ public class TorBoxDebridClientTest
var torrentsApiMock = new Mock<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings)
{
CallBase = true
};
@ -653,7 +649,7 @@ public class TorBoxDebridClientTest
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User>
@ -682,7 +678,7 @@ public class TorBoxDebridClientTest
var torrentsApiMock = new Mock<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings)
{
CallBase = true
};
@ -691,7 +687,7 @@ public class TorBoxDebridClientTest
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User>
@ -721,7 +717,7 @@ public class TorBoxDebridClientTest
var torrentsApiMock = new Mock<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings)
{
CallBase = true
};
@ -730,7 +726,7 @@ public class TorBoxDebridClientTest
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User>
@ -764,7 +760,7 @@ public class TorBoxDebridClientTest
var torrentsApiMock = new Mock<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings)
{
CallBase = true
};
@ -773,7 +769,7 @@ public class TorBoxDebridClientTest
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User>
@ -803,7 +799,7 @@ public class TorBoxDebridClientTest
var nzbLink = "https://example.com/test.nzb";
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings)
{
CallBase = true
};
@ -811,7 +807,7 @@ public class TorBoxDebridClientTest
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.AddLinkAsync(nzbLink, It.IsAny<Int32>(), It.IsAny<String?>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
@ -834,7 +830,7 @@ public class TorBoxDebridClientTest
var name = "test.nzb";
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings)
{
CallBase = true
};
@ -842,7 +838,7 @@ public class TorBoxDebridClientTest
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient", ItExpr.IsAny<String>()).Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny<Int32>(), name, It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
@ -869,7 +865,7 @@ public class TorBoxDebridClientTest
Filename = "test-file"
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
// Act
var result = await clientMock.Object.UpdateData(torrent, torrentClientTorrent);
@ -895,7 +891,7 @@ public class TorBoxDebridClientTest
Filename = "test-torrent"
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
// Act
await clientMock.Object.UpdateData(torrent, torrentClientTorrent);
@ -908,4 +904,30 @@ public class TorBoxDebridClientTest
It.Is<Func<It.IsAnyType, Exception?, String>>((v, t) => true)),
Times.Once);
}
[Fact]
public async Task UpdateData_SetsProcessingStatus_WhenTorBoxStatusIsUnmappedAndTorrentWasQueued()
{
// Arrange
var torrent = new Torrent
{
RdId = "test-rd-id",
RdStatus = TorrentStatus.Queued,
RdName = "test-torrent"
};
var torrentClientTorrent = new DebridClientTorrent
{
Status = "some-unknown-status",
Filename = "test-torrent"
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object, _settings);
// Act
var result = await clientMock.Object.UpdateData(torrent, torrentClientTorrent);
// Assert
Assert.Equal(TorrentStatus.Processing, result.RdStatus);
}
}

View file

@ -0,0 +1,77 @@
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Data;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Service.Helpers;
using RdtClient.Service.Services;
namespace RdtClient.Service.Test.Services;
public class TorrentRunnerTest
{
[Fact]
public async Task Tick_ShouldNotRequeueCompletedErrorTorrent()
{
var testSettings = new TestSettings();
var runnerState = new TorrentRunnerState();
testSettings.Current.Provider.ApiKey = "test-api-key";
testSettings.Current.Provider.Provider = Provider.RealDebrid;
testSettings.Current.Provider.MaxParallelDownloads = 1;
testSettings.Current.DownloadClient.DownloadPath = "/downloads";
var erroredTorrent = new Torrent
{
TorrentId = Guid.NewGuid(),
Hash = "hash-1",
RdName = "Torrent 1",
FileOrMagnet = "magnet:?xt=urn:btih:hash-1",
Type = DownloadType.Torrent,
RdStatus = TorrentStatus.Queued,
DeleteOnError = 10,
Error = "Could not add to provider: Infringing file",
Completed = DateTimeOffset.UtcNow.AddMinutes(-5),
Downloads = new List<Download>()
};
var torrentDataMock = new Mock<ITorrentData>(MockBehavior.Strict);
torrentDataMock.Setup(m => m.Get())
.ReturnsAsync(new List<Torrent>
{
erroredTorrent
});
var torrents = new Torrents(Mock.Of<ILogger<Torrents>>(),
torrentDataMock.Object,
Mock.Of<IDownloads>(),
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
testSettings,
runnerState);
var torrentRunner = new TorrentRunner(Mock.Of<ILogger<TorrentRunner>>(),
torrents,
new(null!),
new(null!, torrents),
Mock.Of<IHttpClientFactory>(),
new RateLimitCoordinator(),
testSettings,
runnerState);
await torrentRunner.Tick();
torrentDataMock.Verify(m => m.UpdateComplete(It.IsAny<Guid>(),
It.IsAny<String?>(),
It.IsAny<DateTimeOffset?>(),
It.IsAny<Boolean>()),
Times.Never);
}
}

View file

@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO.Abstractions.TestingHelpers;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
@ -9,7 +10,6 @@ using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services;
using RdtClient.Service.Test.Helpers;
using RdtClient.Service.Wrappers;
using DownloadClient = RdtClient.Data.Enums.DownloadClient;
using TorrentsService = RdtClient.Service.Services.Torrents;
@ -90,24 +90,19 @@ public class TorrentsTest
mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult<Torrent?>(torrent));
mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads);
String downloadPath;
String torrentPath;
String filePath;
var category = torrent.Category!;
var torrentName = torrent.RdName!;
var fileName = downloads[0].FileName!;
if (OSHelper.IsLinux)
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}";
torrentPath = $"{downloadPath}/{torrent.RdName}";
filePath = $"{torrentPath}/{downloads[0].FileName}";
}
else
{
Settings.Get.DownloadClient.DownloadPath = settings.DownloadClient.DownloadPath = @"C:\Downloads";
downloadPath = @$"{settings.DownloadClient.DownloadPath}\{torrent.Category}";
torrentPath = @$"{downloadPath}\{torrent.RdName}";
filePath = @$"{torrentPath}\{downloads[0].FileName}";
settings.DownloadClient.DownloadPath = @"C:\Downloads";
}
var downloadPath = Path.Combine(settings.DownloadClient.DownloadPath, category);
var torrentPath = Path.Combine(downloadPath, torrentName);
var filePath = Path.Combine(torrentPath, fileName);
var fileSystemMock = new MockFileSystem(new Dictionary<String, MockFileData>
{
{
@ -125,7 +120,9 @@ public class TorrentsTest
null!,
null!,
null!,
null!);
null!,
new TestSettings(),
new TorrentRunnerState());
mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny<Int32>())).Returns(true);
@ -170,9 +167,9 @@ public class TorrentsTest
mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult<Torrent?>(torrent));
mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads);
var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}";
var torrentPath = $"{downloadPath}/{torrent.RdName}";
var filePath = $"{torrentPath}/{downloads[0].FileName}";
var downloadPath = Path.Combine(settings.DownloadClient.DownloadPath, torrent.Category ?? "");
var torrentPath = Path.Combine(downloadPath, torrent.RdName ?? "");
var filePath = Path.Combine(torrentPath, downloads[0].FileName ?? "");
var fileSystemMock = new MockFileSystem(new Dictionary<String, MockFileData>
{
@ -191,7 +188,9 @@ public class TorrentsTest
null!,
null!,
null!,
null!);
null!,
new TestSettings(),
new TorrentRunnerState());
//Act
await torrents.RunTorrentComplete(torrent.TorrentId, settings);
@ -218,9 +217,9 @@ public class TorrentsTest
mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult<Torrent?>(torrent));
mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads);
var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}";
var torrentPath = $"{downloadPath}/{torrent.RdName}";
var filePath = $"{torrentPath}/{downloads[0].FileName}";
var downloadPath = Path.Combine(settings.DownloadClient.DownloadPath, torrent.Category ?? "");
var torrentPath = Path.Combine(downloadPath, torrent.RdName ?? "");
var filePath = Path.Combine(torrentPath, downloads[0].FileName ?? "");
var fileSystemMock = new MockFileSystem(new Dictionary<String, MockFileData>
{
@ -239,7 +238,9 @@ public class TorrentsTest
null!,
null!,
null!,
null!);
null!,
new TestSettings(),
new TorrentRunnerState());
mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny<Int32>()))
.Callback(() =>
@ -285,9 +286,9 @@ public class TorrentsTest
mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult<Torrent?>(torrent));
mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads);
var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}";
var torrentPath = $"{downloadPath}/{torrent.RdName}";
var filePath = $"{torrentPath}/{downloads[0].FileName}";
var downloadPath = Path.Combine(settings.DownloadClient.DownloadPath, torrent.Category ?? "");
var torrentPath = Path.Combine(downloadPath, torrent.RdName ?? "");
var filePath = Path.Combine(torrentPath, downloads[0].FileName ?? "");
var fileSystemMock = new MockFileSystem(new Dictionary<String, MockFileData>
{
@ -306,7 +307,9 @@ public class TorrentsTest
null!,
null!,
null!,
null!);
null!,
new TestSettings(),
new TorrentRunnerState());
mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny<Int32>()))
.Callback(() =>
@ -369,7 +372,9 @@ public class TorrentsTest
null!,
null!,
null!,
null!);
null!,
new TestSettings(),
new TorrentRunnerState());
// Act
await torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent);
@ -417,7 +422,9 @@ public class TorrentsTest
null!,
null!,
null!,
null!);
null!,
new TestSettings(),
new TorrentRunnerState());
// Act
await torrents.AddNzbLinkToDebridQueue(link, torrent);

View file

@ -0,0 +1,59 @@
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services;
namespace RdtClient.Service.Test;
internal sealed class TestSettings(DbSettings? settings = null) : ISettings
{
public DbSettings Current { get; } = settings ?? new();
public String DefaultSavePath
{
get
{
var downloadPath = Current.DownloadClient.MappedPath.TrimEnd('\\').TrimEnd('/');
return downloadPath + Path.DirectorySeparatorChar;
}
}
public IList<SettingProperty> GetAll()
{
return [];
}
public Task Update(IList<SettingProperty> settings)
{
return Task.CompletedTask;
}
public Task Update(String settingId, Object? value)
{
SetValue(Current, settingId.Split(':'), 0, value);
return Task.CompletedTask;
}
private static void SetValue(Object target, IReadOnlyList<String> path, Int32 index, Object? value)
{
var property = target.GetType().GetProperty(path[index]) ?? throw new($"Unknown setting {String.Join(":", path)}");
if (index < path.Count - 1)
{
SetValue(property.GetValue(target)!, path, index + 1, value);
return;
}
if (value == null)
{
property.SetValue(target, null);
return;
}
var propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
var convertedValue = propertyType.IsEnum ? Enum.Parse(propertyType, value.ToString()!) : Convert.ChangeType(value, propertyType);
property.SetValue(target, convertedValue);
}
}

View file

@ -8,7 +8,7 @@ using DownloadClient = RdtClient.Data.Enums.DownloadClient;
namespace RdtClient.Service.BackgroundServices;
public class DiskSpaceMonitor(ILogger<DiskSpaceMonitor> logger, IServiceProvider serviceProvider) : BackgroundService
public class DiskSpaceMonitor(ILogger<DiskSpaceMonitor> logger, IServiceProvider serviceProvider, ISettings settings, ITorrentRunnerState runnerState) : BackgroundService
{
private static DiskSpaceStatus? _lastStatus;
private Boolean _isPausedForLowDiskSpace;
@ -34,7 +34,7 @@ public class DiskSpaceMonitor(ILogger<DiskSpaceMonitor> logger, IServiceProvider
{
try
{
var minimumFreeSpaceGB = Settings.Get.DownloadClient.MinimumFreeSpaceGB;
var minimumFreeSpaceGB = settings.Current.DownloadClient.MinimumFreeSpaceGB;
if (minimumFreeSpaceGB <= 0)
{
@ -43,14 +43,14 @@ public class DiskSpaceMonitor(ILogger<DiskSpaceMonitor> logger, IServiceProvider
continue;
}
var intervalMinutes = Settings.Get.DownloadClient.DiskSpaceCheckIntervalMinutes;
var intervalMinutes = settings.Current.DownloadClient.DiskSpaceCheckIntervalMinutes;
if (intervalMinutes < 1)
{
intervalMinutes = 1;
}
var downloadPath = Settings.Get.DownloadClient.DownloadPath;
var downloadPath = settings.Current.DownloadClient.DownloadPath;
logger.LogDebug($"Checking disk space for path: {downloadPath}");
if (!Directory.Exists(downloadPath))
@ -78,7 +78,7 @@ public class DiskSpaceMonitor(ILogger<DiskSpaceMonitor> logger, IServiceProvider
var pausedCount = 0;
foreach (var download in TorrentRunner.ActiveDownloadClients)
foreach (var download in runnerState.ActiveDownloadClients)
{
if (download.Value.Type == DownloadClient.Bezzad)
{
@ -90,7 +90,7 @@ public class DiskSpaceMonitor(ILogger<DiskSpaceMonitor> logger, IServiceProvider
logger.LogInformation($"Paused {pausedCount} active Bezzad downloads");
TorrentRunner.IsPausedForLowDiskSpace = true;
runnerState.IsPausedForLowDiskSpace = true;
_isPausedForLowDiskSpace = true;
var status = new DiskSpaceStatus
@ -125,7 +125,7 @@ public class DiskSpaceMonitor(ILogger<DiskSpaceMonitor> logger, IServiceProvider
var resumedCount = 0;
foreach (var download in TorrentRunner.ActiveDownloadClients)
foreach (var download in runnerState.ActiveDownloadClients)
{
if (download.Value.Type == DownloadClient.Bezzad)
{
@ -137,7 +137,7 @@ public class DiskSpaceMonitor(ILogger<DiskSpaceMonitor> logger, IServiceProvider
logger.LogInformation($"Resumed {resumedCount} Bezzad downloads");
TorrentRunner.IsPausedForLowDiskSpace = false;
runnerState.IsPausedForLowDiskSpace = false;
_isPausedForLowDiskSpace = false;
var status = new DiskSpaceStatus

View file

@ -7,7 +7,7 @@ using RdtClient.Service.Services;
namespace RdtClient.Service.BackgroundServices;
public class ProviderUpdater(ILogger<ProviderUpdater> logger, IServiceProvider serviceProvider) : BackgroundService
public class ProviderUpdater(ILogger<ProviderUpdater> logger, IServiceProvider serviceProvider, ISettings settings) : BackgroundService
{
private static DateTime _nextUpdate = DateTime.UtcNow;
@ -18,22 +18,23 @@ public class ProviderUpdater(ILogger<ProviderUpdater> logger, IServiceProvider s
await Task.Delay(1000, stoppingToken);
}
using var scope = serviceProvider.CreateScope();
var torrentService = scope.ServiceProvider.GetRequiredService<Torrents>();
var torrentRunner = scope.ServiceProvider.GetRequiredService<TorrentRunner>();
logger.LogInformation("ProviderUpdater started.");
while (!stoppingToken.IsCancellationRequested)
{
using var scope = serviceProvider.CreateScope();
var torrentService = scope.ServiceProvider.GetRequiredService<Torrents>();
var torrentRunner = scope.ServiceProvider.GetRequiredService<TorrentRunner>();
try
{
var torrents = await torrentService.Get();
if (_nextUpdate < DateTime.UtcNow && (Settings.Get.Provider.AutoImport || torrents.Any(t => t.RdStatus != TorrentStatus.Finished) || RdtHub.HasConnections))
if (_nextUpdate < DateTime.UtcNow && (settings.Current.Provider.AutoImport || torrents.Any(t => t.RdStatus != TorrentStatus.Finished) || RdtHub.HasConnections))
{
logger.LogDebug($"Updating torrent info from debrid provider");
var updateTime = Settings.Get.Provider.CheckInterval * 3;
var updateTime = settings.Current.Provider.CheckInterval * 3;
if (updateTime < 30)
{
@ -42,7 +43,7 @@ public class ProviderUpdater(ILogger<ProviderUpdater> logger, IServiceProvider s
if (RdtHub.HasConnections)
{
updateTime = Settings.Get.Provider.CheckInterval;
updateTime = settings.Current.Provider.CheckInterval;
if (updateTime < 5)
{

View file

@ -1,4 +1,5 @@
using System.Reflection;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@ -39,6 +40,9 @@ public class Startup(IServiceProvider serviceProvider) : IHostedService
public Task StopAsync(CancellationToken cancellationToken)
{
Ready = false;
SqliteConnection.ClearAllPools();
return Task.CompletedTask;
}
}

View file

@ -16,15 +16,19 @@ public class TaskRunner(ILogger<TaskRunner> logger, IServiceProvider serviceProv
await Task.Delay(1000, stoppingToken);
}
using var scope = serviceProvider.CreateScope();
var torrentRunner = scope.ServiceProvider.GetRequiredService<TorrentRunner>();
logger.LogInformation("TaskRunner started.");
await torrentRunner.Initialize();
using (var startupScope = serviceProvider.CreateScope())
{
var startupRunner = startupScope.ServiceProvider.GetRequiredService<TorrentRunner>();
await startupRunner.Initialize();
}
while (!stoppingToken.IsCancellationRequested)
{
using var scope = serviceProvider.CreateScope();
var torrentRunner = scope.ServiceProvider.GetRequiredService<TorrentRunner>();
try
{
await torrentRunner.Tick();

View file

@ -8,7 +8,7 @@ using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace RdtClient.Service.BackgroundServices;
public class WatchFolderChecker(ILogger<WatchFolderChecker> logger, IServiceProvider serviceProvider) : BackgroundService
public class WatchFolderChecker(ILogger<WatchFolderChecker> logger, IServiceProvider serviceProvider, ISettings settings) : BackgroundService
{
private DateTime _prevCheck = DateTime.MinValue;
@ -28,7 +28,7 @@ public class WatchFolderChecker(ILogger<WatchFolderChecker> logger, IServiceProv
{
try
{
var nextCheck = _prevCheck.AddSeconds(Math.Max(Settings.Get.Watch.Interval, 10));
var nextCheck = _prevCheck.AddSeconds(Math.Max(settings.Current.Watch.Interval, 10));
if (DateTime.Now < nextCheck)
{
@ -38,25 +38,25 @@ public class WatchFolderChecker(ILogger<WatchFolderChecker> logger, IServiceProv
_prevCheck = DateTime.Now;
if (String.IsNullOrWhiteSpace(Settings.Get.Watch.Path))
if (String.IsNullOrWhiteSpace(settings.Current.Watch.Path))
{
continue;
}
var processedStorePath = Path.Combine(Settings.Get.Watch.Path, "processed");
var errorStorePath = Path.Combine(Settings.Get.Watch.Path, "error");
var processedStorePath = Path.Combine(settings.Current.Watch.Path, "processed");
var errorStorePath = Path.Combine(settings.Current.Watch.Path, "error");
if (!String.IsNullOrWhiteSpace(Settings.Get.Watch.ProcessedPath))
if (!String.IsNullOrWhiteSpace(settings.Current.Watch.ProcessedPath))
{
processedStorePath = Settings.Get.Watch.ProcessedPath;
processedStorePath = settings.Current.Watch.ProcessedPath;
}
if (!String.IsNullOrWhiteSpace(Settings.Get.Watch.ErrorPath))
if (!String.IsNullOrWhiteSpace(settings.Current.Watch.ErrorPath))
{
errorStorePath = Settings.Get.Watch.ErrorPath;
errorStorePath = settings.Current.Watch.ErrorPath;
}
var torrentFiles = Directory.GetFiles(Settings.Get.Watch.Path, "*.*", SearchOption.TopDirectoryOnly);
var torrentFiles = Directory.GetFiles(settings.Current.Watch.Path, "*.*", SearchOption.TopDirectoryOnly);
foreach (var torrentFile in torrentFiles)
{
@ -78,22 +78,22 @@ public class WatchFolderChecker(ILogger<WatchFolderChecker> logger, IServiceProv
var torrent = new Torrent
{
DownloadClient = Settings.Get.DownloadClient.Client,
Category = Settings.Get.Watch.Default.Category,
HostDownloadAction = Settings.Get.Watch.Default.HostDownloadAction,
FinishedActionDelay = Settings.Get.Watch.Default.FinishedActionDelay,
DownloadAction = Settings.Get.Watch.Default.OnlyDownloadAvailableFiles
DownloadClient = settings.Current.DownloadClient.Client,
Category = settings.Current.Watch.Default.Category,
HostDownloadAction = settings.Current.Watch.Default.HostDownloadAction,
FinishedActionDelay = settings.Current.Watch.Default.FinishedActionDelay,
DownloadAction = settings.Current.Watch.Default.OnlyDownloadAvailableFiles
? TorrentDownloadAction.DownloadAvailableFiles
: TorrentDownloadAction.DownloadAll,
FinishedAction = Settings.Get.Watch.Default.FinishedAction,
DownloadMinSize = Settings.Get.Watch.Default.MinFileSize,
IncludeRegex = Settings.Get.Watch.Default.IncludeRegex,
ExcludeRegex = Settings.Get.Watch.Default.ExcludeRegex,
TorrentRetryAttempts = Settings.Get.Watch.Default.TorrentRetryAttempts,
DownloadRetryAttempts = Settings.Get.Watch.Default.DownloadRetryAttempts,
DeleteOnError = Settings.Get.Watch.Default.DeleteOnError,
Lifetime = Settings.Get.Watch.Default.TorrentLifetime,
Priority = Settings.Get.Watch.Default.Priority > 0 ? Settings.Get.Watch.Default.Priority : null
FinishedAction = settings.Current.Watch.Default.FinishedAction,
DownloadMinSize = settings.Current.Watch.Default.MinFileSize,
IncludeRegex = settings.Current.Watch.Default.IncludeRegex,
ExcludeRegex = settings.Current.Watch.Default.ExcludeRegex,
TorrentRetryAttempts = settings.Current.Watch.Default.TorrentRetryAttempts,
DownloadRetryAttempts = settings.Current.Watch.Default.DownloadRetryAttempts,
DeleteOnError = settings.Current.Watch.Default.DeleteOnError,
Lifetime = settings.Current.Watch.Default.TorrentLifetime,
Priority = settings.Current.Watch.Default.Priority > 0 ? settings.Current.Watch.Default.Priority : null
};
if (fileInfo.Extension == ".torrent")

View file

@ -6,7 +6,6 @@ using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Timeout;
using RateLimitHeaders.Polly;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Helpers;
using RdtClient.Service.Middleware;
@ -20,6 +19,7 @@ public static class DiConfig
{
public const String RD_CLIENT = "RdClient";
public const String TORBOX_CLIENT = "TorBoxClient";
public const String TORBOX_CLIENT_SLOW = "TorBoxClientSlow";
public static readonly String UserAgent = $"rdt-client {Assembly.GetEntryAssembly()?.GetName().Version}";
public static void RegisterRdtServices(this IServiceCollection services)
@ -29,6 +29,8 @@ public static class DiConfig
services.AddSingleton<IAllDebridNetClientFactory, AllDebridNetClientFactory>();
services.AddScoped<AllDebridDebridClient>();
services.AddSingleton<IRateLimitCoordinator, RateLimitCoordinator>();
services.AddSingleton(TorrentRunner.SharedState);
services.AddSingleton<IProcessFactory, ProcessFactory>();
services.AddSingleton<IFileSystem, FileSystem>();
@ -40,7 +42,8 @@ public static class DiConfig
services.AddScoped<Sabnzbd>();
services.AddScoped<RemoteService>();
services.AddScoped<RealDebridDebridClient>();
services.AddScoped<Settings>();
services.AddSingleton<Settings>();
services.AddSingleton<ISettings>(serviceProvider => serviceProvider.GetRequiredService<Settings>());
services.AddScoped<TorBoxDebridClient>();
services.AddScoped<Torrents>();
services.AddScoped<TorrentRunner>();
@ -50,7 +53,7 @@ public static class DiConfig
services.AddSingleton<ITrackerListGrabber, TrackerListGrabber>();
services.AddSingleton<IEnricher, Enricher>();
services.AddSingleton<IAuthorizationHandler, AuthSettingHandler>();
services.AddScoped<IAuthorizationHandler, AuthSettingHandler>();
services.AddScoped<IAuthorizationHandler, SabnzbdHandler>();
services.AddHostedService<DiskSpaceMonitor>();
@ -83,17 +86,15 @@ public static class DiConfig
// This likely works for most providers, but should be verified and then the providers changed
// to this HTTP client for added resilience.
services.AddHttpClient(TORBOX_CLIENT)
.AddHttpMessageHandler<RateLimitHandler>()
.AddResilienceHandler("torbox_client_handler", ConfigureResiliencePipeline);
services.AddHttpClient(TORBOX_CLIENT_SLOW)
.AddHttpMessageHandler<RateLimitHandler>()
.AddResilienceHandler("torbox_client_handler_slow", ConfigureResiliencePipeline);
}
private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder<HttpResponseMessage> builder)
{
builder.AddTimeout(new TimeoutStrategyOptions
{
TimeoutGenerator = _ => new(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout))
});
builder.AddRateLimitHeaders(options =>
{
options.EnableProactiveThrottling = true;
@ -116,17 +117,12 @@ public static class DiConfig
{
if (args.Outcome.Result is { StatusCode: HttpStatusCode.TooManyRequests } response)
{
var retryAfter = response.Headers.RetryAfter;
var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2));
var delay = RateLimitHandler.GetRetryAfterDelay(response);
var timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout);
if (delay < TimeSpan.Zero)
if (delay >= timeout)
{
delay = TimeSpan.FromMinutes(2);
}
if (delay >= TimeSpan.FromSeconds(Settings.Get.Provider.Timeout))
{
throw new RateLimitException("Provider rate limit exceeded", delay);
return new((TimeSpan?)null);
}
return new(delay);
@ -135,5 +131,10 @@ public static class DiConfig
return new((TimeSpan?)null);
}
});
builder.AddTimeout(new TimeoutStrategyOptions
{
TimeoutGenerator = _ => new(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout))
});
}
}

View file

@ -38,7 +38,12 @@ public static class DownloadHelper
{
subPath = subPath.Trim('/').Trim('\\');
torrentPath = Path.Combine(torrentPath, subPath);
subPath = StripTorrentNamePrefix(subPath, directory);
if (!String.IsNullOrWhiteSpace(subPath))
{
torrentPath = Path.Combine(torrentPath, subPath);
}
}
}
@ -87,7 +92,12 @@ public static class DownloadHelper
{
subPath = subPath.Trim('/').Trim('\\');
torrentPath = Path.Combine(torrentPath, subPath);
subPath = StripTorrentNamePrefix(subPath, torrentPath);
if (!String.IsNullOrWhiteSpace(subPath))
{
torrentPath = Path.Combine(torrentPath, subPath);
}
}
}
@ -117,4 +127,32 @@ public static class DownloadHelper
{
return String.Concat(path.Split(Path.GetInvalidPathChars()));
}
private static String StripTorrentNamePrefix(String subPath, String torrentName)
{
var separatorIndex = subPath.IndexOfAny(['/', '\\']);
String firstComponent;
String remainder;
if (separatorIndex < 0)
{
firstComponent = subPath;
remainder = String.Empty;
}
else
{
firstComponent = subPath[..separatorIndex];
remainder = subPath[(separatorIndex + 1)..];
}
var normalizedFirst = RemoveInvalidPathChars(firstComponent);
if (normalizedFirst.Equals(torrentName.TrimEnd('/', '\\'), StringComparison.OrdinalIgnoreCase))
{
return remainder;
}
return subPath;
}
}

View file

@ -0,0 +1,10 @@
namespace RdtClient.Service.Helpers;
public interface IRateLimitCoordinator
{
TimeSpan GetRemainingCooldown(String key);
TimeSpan GetMaxRemainingCooldown();
DateTimeOffset? GetMaxNextAllowedAt();
void UpdateCooldown(String key, TimeSpan retryAfter);
DateTimeOffset? GetNextAllowedAt(String key);
}

View file

@ -0,0 +1,94 @@
using System.Collections.Concurrent;
namespace RdtClient.Service.Helpers;
public class RateLimitCoordinator : IRateLimitCoordinator
{
private readonly ConcurrentDictionary<String, DateTimeOffset> _cooldowns = new();
public TimeSpan GetRemainingCooldown(String key)
{
if (_cooldowns.TryGetValue(key, out var nextAllowedAt))
{
var remaining = nextAllowedAt - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero)
{
return remaining;
}
_cooldowns.TryRemove(key, out _);
}
return TimeSpan.Zero;
}
public TimeSpan GetMaxRemainingCooldown()
{
var now = DateTimeOffset.UtcNow;
var max = TimeSpan.Zero;
foreach (var (key, nextAllowedAt) in _cooldowns)
{
var remaining = nextAllowedAt - now;
if (remaining > TimeSpan.Zero)
{
if (remaining > max)
{
max = remaining;
}
}
else
{
_cooldowns.TryRemove(key, out _);
}
}
return max;
}
public DateTimeOffset? GetMaxNextAllowedAt()
{
var now = DateTimeOffset.UtcNow;
DateTimeOffset? max = null;
foreach (var (key, nextAllowedAt) in _cooldowns)
{
if (nextAllowedAt > now)
{
if (max == null || nextAllowedAt > max)
{
max = nextAllowedAt;
}
}
else
{
_cooldowns.TryRemove(key, out _);
}
}
return max;
}
public void UpdateCooldown(String key, TimeSpan retryAfter)
{
var nextAllowedAt = DateTimeOffset.UtcNow.Add(retryAfter);
_cooldowns.AddOrUpdate(key, nextAllowedAt, (_, existing) => nextAllowedAt > existing ? nextAllowedAt : existing);
}
public DateTimeOffset? GetNextAllowedAt(String key)
{
if (_cooldowns.TryGetValue(key, out var nextAllowedAt))
{
if (nextAllowedAt > DateTimeOffset.UtcNow)
{
return nextAllowedAt;
}
_cooldowns.TryRemove(key, out _);
}
return null;
}
}

View file

@ -1,37 +1,61 @@
using System.Net;
using Polly.Timeout;
using Polly;
using RdtClient.Data.Models.Internal;
namespace RdtClient.Service.Helpers;
public class RateLimitHandler : DelegatingHandler
public class RateLimitHandler(IRateLimitCoordinator coordinator) : DelegatingHandler
{
public static readonly ResiliencePropertyKey<DateTimeOffset> StartTimeKey = new("StartTime");
public static TimeSpan GetRetryAfterDelay(HttpResponseMessage response)
{
var retryAfter = response.Headers.RetryAfter;
var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2));
if (delay < TimeSpan.Zero)
{
delay = TimeSpan.FromMinutes(2);
}
return delay;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
try
var host = request.RequestUri?.Host ?? "unknown";
var cooldown = coordinator.GetRemainingCooldown(host);
if (cooldown > TimeSpan.Zero)
{
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter;
var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2));
if (delay < TimeSpan.Zero)
{
delay = TimeSpan.FromMinutes(2);
}
response.Dispose();
throw new RateLimitException("TorBox rate limit exceeded", delay);
}
return response;
throw new RateLimitException($"Rate limit cooldown active for {host}", cooldown);
}
catch (Exception ex) when (ex is TimeoutRejectedException or TaskCanceledException)
var context = request.GetResilienceContext();
if (context == null)
{
throw new RateLimitException("Provider rate limit exceeded (timeout)", TimeSpan.FromMinutes(2));
context = ResilienceContextPool.Shared.Get(cancellationToken);
request.SetResilienceContext(context);
}
if (!context.Properties.TryGetValue(StartTimeKey, out _))
{
context.Properties.Set(StartTimeKey, DateTimeOffset.UtcNow);
}
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var delay = GetRetryAfterDelay(response);
coordinator.UpdateCooldown(host, delay);
response.Dispose();
throw new RateLimitException($"Provider {host} rate limit exceeded", delay);
}
return response;
}
}

View file

@ -0,0 +1,232 @@
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
namespace RdtClient.Service.Helpers;
public static class TorrentDtoMapper
{
public static TorrentDto ToListDto(Torrent torrent, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
return ToDto(torrent, getDownloadStats, includeDownloads: false, includeFiles: false, includeFileOrMagnet: false);
}
public static TorrentDto ToUpdateDto(Torrent torrent, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
return ToDto(torrent, getDownloadStats, includeDownloads: true, includeFiles: false, includeFileOrMagnet: false);
}
public static TorrentDto ToDetailDto(Torrent torrent, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
return ToDto(torrent, getDownloadStats, includeDownloads: true, includeFiles: true, includeFileOrMagnet: true);
}
private static TorrentDto ToDto(Torrent torrent,
Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats,
Boolean includeDownloads,
Boolean includeFiles,
Boolean includeFileOrMagnet)
{
var downloads = includeDownloads ? torrent.Downloads.Select(download => ToDto(download, getDownloadStats)).ToList() : [];
return new()
{
TorrentId = torrent.TorrentId,
Hash = torrent.Hash,
Category = torrent.Category,
DownloadAction = torrent.DownloadAction,
FinishedAction = torrent.FinishedAction,
FinishedActionDelay = torrent.FinishedActionDelay,
HostDownloadAction = torrent.HostDownloadAction,
DownloadMinSize = torrent.DownloadMinSize,
IncludeRegex = torrent.IncludeRegex,
ExcludeRegex = torrent.ExcludeRegex,
DownloadManualFiles = torrent.DownloadManualFiles,
DownloadClient = torrent.DownloadClient,
Added = torrent.Added,
FilesSelected = torrent.FilesSelected,
Completed = torrent.Completed,
Type = torrent.Type,
FileOrMagnet = includeFileOrMagnet ? torrent.FileOrMagnet : null,
IsFile = torrent.IsFile,
Priority = torrent.Priority,
RetryCount = torrent.RetryCount,
DownloadRetryAttempts = torrent.DownloadRetryAttempts,
TorrentRetryAttempts = torrent.TorrentRetryAttempts,
DeleteOnError = torrent.DeleteOnError,
Lifetime = torrent.Lifetime,
Error = torrent.Error,
RdId = torrent.RdId,
RdName = torrent.RdName,
RdSize = torrent.RdSize,
RdHost = torrent.RdHost,
RdSplit = torrent.RdSplit,
RdProgress = torrent.RdProgress,
RdStatus = torrent.RdStatus,
RdStatusRaw = torrent.RdStatusRaw,
RdAdded = torrent.RdAdded,
RdEnded = torrent.RdEnded,
RdSpeed = torrent.RdSpeed,
RdSeeders = torrent.RdSeeders,
StatusText = GetStatusText(torrent, getDownloadStats),
FilesCount = torrent.Files.Count,
DownloadsCount = torrent.Downloads.Count,
Files = includeFiles ? torrent.Files : [],
Downloads = downloads
};
}
private static DownloadDto ToDto(Download download, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
var (speed, bytesTotal, bytesDone) = getDownloadStats(download.DownloadId);
return new()
{
DownloadId = download.DownloadId,
TorrentId = download.TorrentId,
Path = download.Path,
Link = download.Link,
Added = download.Added,
DownloadQueued = download.DownloadQueued,
DownloadStarted = download.DownloadStarted,
DownloadFinished = download.DownloadFinished,
UnpackingQueued = download.UnpackingQueued,
UnpackingStarted = download.UnpackingStarted,
UnpackingFinished = download.UnpackingFinished,
Completed = download.Completed,
RetryCount = download.RetryCount,
Error = download.Error,
BytesTotal = bytesTotal,
BytesDone = bytesDone,
Speed = speed
};
}
private static String GetStatusText(Torrent torrent, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
if (!String.IsNullOrWhiteSpace(torrent.Error))
{
return torrent.Error;
}
if (torrent.Downloads.Count > 0)
{
var allFinished = true;
var downloadingCount = 0;
var downloadedCount = 0;
Int64 downloadingBytesDone = 0;
Int64 downloadingBytesTotal = 0;
Int64 downloadingSpeed = 0;
var unpackingCount = 0;
var unpackedCount = 0;
Int64 unpackingBytesDone = 0;
Int64 unpackingBytesTotal = 0;
var queuedForUnpackingCount = 0;
var queuedForDownloadingCount = 0;
foreach (var download in torrent.Downloads)
{
if (download.Completed == null)
{
allFinished = false;
}
var (speed, bytesTotal, bytesDone) = getDownloadStats(download.DownloadId);
if (download.DownloadFinished != null)
{
downloadedCount += 1;
}
if (download.DownloadStarted != null && download.DownloadFinished == null && bytesDone > 0)
{
downloadingCount += 1;
downloadingBytesDone += bytesDone;
downloadingBytesTotal += bytesTotal;
downloadingSpeed += speed;
}
if (download.UnpackingFinished != null)
{
unpackedCount += 1;
}
if (download.UnpackingStarted != null && download.UnpackingFinished == null && bytesDone > 0)
{
unpackingCount += 1;
unpackingBytesDone += bytesDone;
unpackingBytesTotal += bytesTotal;
}
if (download.UnpackingQueued != null && download.UnpackingStarted == null)
{
queuedForUnpackingCount += 1;
}
if (download.DownloadStarted == null && download.DownloadFinished == null)
{
queuedForDownloadingCount += 1;
}
}
if (allFinished)
{
return "Finished";
}
if (downloadingCount > 0)
{
var progress = downloadingBytesTotal == 0 ? 0 : (Double)downloadingBytesDone / downloadingBytesTotal * 100;
return $"Downloading file {downloadingCount + downloadedCount}/{torrent.Downloads.Count} ({progress:0.00}% - {FileSizeHelper.FormatSize(downloadingSpeed)}/s)";
}
if (unpackingCount > 0)
{
var progress = unpackingBytesTotal == 0 ? 0 : (Double)unpackingBytesDone / unpackingBytesTotal * 100;
return $"Extracting file {unpackingCount + unpackedCount}/{torrent.Downloads.Count} ({progress:0.00}%)";
}
if (queuedForUnpackingCount > 0)
{
return "Queued for unpacking";
}
if (queuedForDownloadingCount > 0)
{
return "Queued for downloading";
}
if (unpackedCount > 0)
{
return "Files unpacked";
}
if (downloadedCount > 0)
{
return "Files downloaded to host";
}
}
if (torrent.Completed != null)
{
return "Finished";
}
var prefix = torrent.Type == DownloadType.Nzb ? "NZB" : "Torrent";
return torrent.RdStatus switch
{
TorrentStatus.Queued => "Not Yet Added to Provider",
TorrentStatus.Downloading when torrent.RdSeeders < 1 && torrent.Type != DownloadType.Nzb => "Torrent stalled",
TorrentStatus.Downloading => $"{prefix} downloading ({torrent.RdProgress}% - {FileSizeHelper.FormatSize(torrent.RdSpeed)}/s)",
TorrentStatus.Processing => $"{prefix} processing",
TorrentStatus.WaitingForFileSelection => $"{prefix} waiting for file selection",
TorrentStatus.Error => $"{prefix} error: {torrent.RdStatusRaw}",
TorrentStatus.Finished => $"{prefix} finished, waiting for download links",
TorrentStatus.Uploading => $"{prefix} uploading",
_ => "Unknown status"
};
}
}

View file

@ -8,7 +8,7 @@ public class AuthSettingRequirement : IAuthorizationRequirement
{
}
public class AuthSettingHandler : AuthorizationHandler<AuthSettingRequirement>
public class AuthSettingHandler(ISettings settings) : AuthorizationHandler<AuthSettingRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthSettingRequirement requirement)
{
@ -17,7 +17,7 @@ public class AuthSettingHandler : AuthorizationHandler<AuthSettingRequirement>
context.Succeed(requirement);
}
if (Settings.Get.General.AuthenticationType == AuthenticationType.None)
if (settings.Current.General.AuthenticationType == AuthenticationType.None)
{
context.Succeed(requirement);
}

View file

@ -9,7 +9,7 @@ public class SabnzbdRequirement : IAuthorizationRequirement
{
}
public class SabnzbdHandler(Authentication authentication, IHttpContextAccessor httpContextAccessor) : AuthorizationHandler<SabnzbdRequirement>
public class SabnzbdHandler(Authentication authentication, IHttpContextAccessor httpContextAccessor, ISettings settings) : AuthorizationHandler<SabnzbdRequirement>
{
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, SabnzbdRequirement requirement)
{
@ -22,7 +22,7 @@ public class SabnzbdHandler(Authentication authentication, IHttpContextAccessor
return;
}
if (Settings.Get.General.AuthenticationType == AuthenticationType.None)
if (settings.Current.General.AuthenticationType == AuthenticationType.None)
{
context.Succeed(requirement);
@ -65,6 +65,36 @@ public class SabnzbdHandler(Authentication authentication, IHttpContextAccessor
return;
}
var apiKey = GetParam("apikey");
if (!String.IsNullOrWhiteSpace(apiKey))
{
var separatorIndex = apiKey.IndexOf(':');
if (separatorIndex <= 0 || separatorIndex == apiKey.Length - 1)
{
context.Fail();
return;
}
var username = apiKey[..separatorIndex];
var password = apiKey[(separatorIndex + 1)..];
var loginResult = await authentication.Login(username, password);
if (loginResult.Succeeded)
{
context.Succeed(requirement);
return;
}
context.Fail();
return;
}
// Authentication required but missing credentials
context.Fail();
}

View file

@ -11,22 +11,22 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="AllDebrid.NET" Version="1.0.18" />
<PackageReference Include="Aria2.NET" Version="1.0.6" />
<PackageReference Include="DebridLinkFr.NET" Version="1.0.4" />
<PackageReference Include="Downloader" Version="4.1.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.3.0" />
<PackageReference Include="DebridLinkFr.NET" Version="2.0.0" />
<PackageReference Include="Downloader" Version="5.5.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageReference Include="MonoTorrent" Version="3.0.2" />
<PackageReference Include="Polly" Version="8.6.5" />
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="Premiumize.NET" Version="1.0.10" />
<PackageReference Include="RateLimitHeaders.Polly" Version="1.0.0" />
<PackageReference Include="RD.NET" Version="2.1.11" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="SharpCompress" Version="0.42.1" />
<PackageReference Include="Synology.Api.Client" Version="0.3.93" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
<PackageReference Include="TorBox.NET" Version="1.6.2" />
<PackageReference Include="SharpCompress" Version="[0.42.1]" />
<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="2.1.0" />
</ItemGroup>
<ItemGroup>

View file

@ -17,13 +17,13 @@ public interface IAllDebridNetClientFactory
public IAllDebridNETClient GetClient();
}
public class AllDebridNetClientFactory(ILogger<AllDebridNetClientFactory> logger, IHttpClientFactory httpClientFactory) : IAllDebridNetClientFactory
public class AllDebridNetClientFactory(ILogger<AllDebridNetClientFactory> logger, IHttpClientFactory httpClientFactory, ISettings settings) : IAllDebridNetClientFactory
{
public IAllDebridNETClient GetClient()
{
try
{
var apiKey = Settings.Get.Provider.ApiKey;
var apiKey = settings.Current.Provider.ApiKey;
if (String.IsNullOrWhiteSpace(apiKey))
{

View file

@ -12,7 +12,7 @@ using Torrent = DebridLinkFrNET.Models.Torrent;
namespace RdtClient.Service.Services.DebridClients;
public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, ISettings settings) : IDebridClient
{
public async Task<IList<DebridClientTorrent>> GetDownloads()
{
@ -228,7 +228,7 @@ public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFacto
{
try
{
var apiKey = Settings.Get.Provider.ApiKey;
var apiKey = settings.Current.Provider.ApiKey;
if (String.IsNullOrWhiteSpace(apiKey))
{
@ -236,7 +236,7 @@ public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFacto
}
var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout);
httpClient.Timeout = TimeSpan.FromSeconds(settings.Current.Provider.Timeout);
var debridLinkClient = new DebridLinkFrNETClient(apiKey, httpClient);

View file

@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Net;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using PremiumizeNET;
@ -11,8 +12,11 @@ using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services.DebridClients;
public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : 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";
public async Task<IList<DebridClientTorrent>> GetDownloads()
{
var results = await GetClient().Transfers.ListAsync();
@ -33,56 +37,22 @@ public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHtt
public async Task<String> AddTorrentMagnet(String magnetLink)
{
try
{
var result = await GetClient().Transfers.CreateAsync(magnetLink, "");
if (result?.Id == null)
{
throw new("Unable to add magnet link");
}
var resultId = result.Id ?? throw new($"Invalid responseID {result.Id}");
return resultId;
}
catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase))
{
throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2));
}
return await CreatePremiumizeNetTransfer(() => GetClient().Transfers.CreateAsync(magnetLink, ""), "magnet link");
}
public async Task<String> AddTorrentFile(Byte[] bytes)
{
try
{
var result = await GetClient().Transfers.CreateAsync(bytes, "");
if (result?.Id == null)
{
throw new("Unable to add torrent file");
}
var resultId = result.Id ?? throw new($"Invalid responseID {result.Id}");
return resultId;
}
catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase))
{
throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2));
}
return await CreatePremiumizeNetTransfer(() => GetClient().Transfers.CreateAsync(bytes, ""), "torrent file");
}
public Task<String> AddNzbLink(String nzbLink)
public async Task<String> AddNzbLink(String nzbLink)
{
throw new NotSupportedException();
return await CreatePremiumizeNetTransfer(() => GetClient().Transfers.CreateAsync(nzbLink, ""), "NZB link");
}
public Task<String> AddNzbFile(Byte[] bytes, String? name)
public async Task<String> AddNzbFile(Byte[] bytes, String? name)
{
throw new NotSupportedException();
return await CreateTransferFromNzbFile(bytes, GetNzbFileName(name));
}
public Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)
@ -225,13 +195,7 @@ public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHtt
{
try
{
var apiKey = Settings.Get.Provider.ApiKey;
if (String.IsNullOrWhiteSpace(apiKey))
{
throw new("Premiumize API Key not set in the settings");
}
var apiKey = GetApiKey();
var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
@ -253,6 +217,146 @@ public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHtt
}
}
private String GetApiKey()
{
var apiKey = settings.Current.Provider.ApiKey;
if (String.IsNullOrWhiteSpace(apiKey))
{
throw new("Premiumize API Key not set in the settings");
}
return apiKey;
}
private async Task<String> CreatePremiumizeNetTransfer(Func<Task<TransferCreateResponse>> createTransfer, String description)
{
try
{
var result = await createTransfer();
if (String.IsNullOrWhiteSpace(result?.Id))
{
throw new($"Unable to add {description}");
}
return result.Id;
}
catch (Exception ex) when (IsRateLimitMessage(ex.Message))
{
throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2));
}
}
private async Task<String> CreateTransferFromNzbFile(Byte[] bytes, String fileName)
{
var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(bytes);
content.Add(fileContent, "src", fileName);
return await CreateTransfer(content, "NZB file");
}
private static String GetNzbFileName(String? name)
{
if (String.IsNullOrWhiteSpace(name))
{
return "upload.nzb";
}
return name.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.nzb";
}
private async Task<String> CreateTransfer(HttpContent content, String description)
{
try
{
using (content)
{
var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
using var request = new HttpRequestMessage(HttpMethod.Post, TransferCreateUrl)
{
Content = content
};
request.Headers.Authorization = new("Bearer", GetApiKey());
using var response = await httpClient.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromMinutes(2);
throw new RateLimitException($"Unable to add {description}: Premiumize rate limit exceeded", retryAfter);
}
if (!response.IsSuccessStatusCode)
{
throw new($"Unable to add {description}: Premiumize returned {(Int32)response.StatusCode} {response.ReasonPhrase}. {responseBody}");
}
var result = JsonConvert.DeserializeObject<RawTransferCreateResponse>(responseBody) ?? throw new($"Unable to add {description}: invalid Premiumize response");
if (!String.Equals(result.Status, "success", StringComparison.OrdinalIgnoreCase))
{
var error = FormatPremiumizeError(result);
if (IsRateLimitMessage(error))
{
throw new RateLimitException(error, TimeSpan.FromMinutes(2));
}
throw new($"Unable to add {description}: {error}");
}
if (String.IsNullOrWhiteSpace(result.Id))
{
throw new($"Unable to add {description}: Premiumize did not return a transfer id");
}
return result.Id;
}
}
catch (RateLimitException)
{
throw;
}
catch (Exception ex) when (IsRateLimitMessage(ex.Message))
{
throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2));
}
}
private static String FormatPremiumizeError(RawTransferCreateResponse result)
{
return String.Join(": ",
new[]
{
result.Code, result.Message
}.Where(m => !String.IsNullOrWhiteSpace(m)));
}
private static Boolean IsRateLimitMessage(String message)
{
return message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) ||
message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase) ||
message.Contains("rate_limit_reached", StringComparison.OrdinalIgnoreCase) ||
message.Contains("account_limit_reached", StringComparison.OrdinalIgnoreCase) ||
message.Contains("service_limit_reached", StringComparison.OrdinalIgnoreCase) ||
message.Contains("service_down", StringComparison.OrdinalIgnoreCase) ||
message.Contains("semi_permanent_error", StringComparison.OrdinalIgnoreCase) ||
message.Contains("too many API requests", StringComparison.OrdinalIgnoreCase) ||
message.Contains("fair-use points", StringComparison.OrdinalIgnoreCase) ||
message.Contains("booster points", StringComparison.OrdinalIgnoreCase) ||
message.Contains("active-job count", StringComparison.OrdinalIgnoreCase) ||
message.Contains("usage limit for this service", StringComparison.OrdinalIgnoreCase) ||
message.Contains("target service is unreachable", StringComparison.OrdinalIgnoreCase) ||
message.Contains("retry after a delay", StringComparison.OrdinalIgnoreCase);
}
private static DebridClientTorrent Map(Transfer transfer)
{
return new()
@ -363,4 +467,19 @@ public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHtt
logger.LogDebug(message);
}
private class RawTransferCreateResponse
{
[JsonProperty("status")]
public String? Status { get; set; }
[JsonProperty("id")]
public String? Id { get; set; }
[JsonProperty("message")]
public String? Message { get; set; }
[JsonProperty("code")]
public String? Code { get; set; }
}
}

View file

@ -12,7 +12,8 @@ using Torrent = RDNET.Torrent;
namespace RdtClient.Service.Services.DebridClients;
public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, ISettings settings)
: IDebridClient
{
private TimeSpan? _offset;
@ -53,7 +54,7 @@ public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHtt
{
try
{
var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout));
var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(settings.Current.Provider.Timeout));
var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, timeoutCancellationToken.Token);
@ -70,7 +71,7 @@ public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHtt
{
try
{
var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout));
var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(settings.Current.Provider.Timeout));
var result = await GetClient().Torrents.AddFileAsync(bytes, timeoutCancellationToken.Token);
@ -314,7 +315,7 @@ public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHtt
{
try
{
var apiKey = Settings.Get.Provider.ApiKey;
var apiKey = settings.Current.Provider.ApiKey;
if (String.IsNullOrWhiteSpace(apiKey))
{
@ -322,9 +323,9 @@ public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHtt
}
var httpClient = httpClientFactory.CreateClient(DiConfig.RD_CLIENT);
httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout);
httpClient.Timeout = TimeSpan.FromSeconds(settings.Current.Provider.Timeout);
var rdtNetClient = new RdNetClient(null, httpClient, 5, Settings.Get.Provider.ApiHostname);
var rdtNetClient = new RdNetClient(null, httpClient, 5, settings.Current.Provider.ApiHostname);
rdtNetClient.UseApiAuthentication(apiKey);
// Get the server time to fix up the timezones on results

View file

@ -11,8 +11,16 @@ using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services.DebridClients;
public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
public class TorBoxDebridClient(
ILogger<TorBoxDebridClient> logger,
IHttpClientFactory httpClientFactory,
IDownloadableFileFilter fileFilter,
IRateLimitCoordinator coordinator,
ISettings settings)
: IDebridClient
{
private const String TorBoxApiHost = "api.torbox.app";
private TimeSpan? _offset;
public async Task<IList<DebridClientTorrent>> GetDownloads()
@ -52,7 +60,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
public async Task<DebridClientUser> GetUser()
{
var user = await GetClient().User.GetAsync(false);
var user = await HandleErrors(() => GetClient().User.GetAsync(false));
return new()
{
@ -66,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().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.");
});
}
@ -77,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().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");
});
}
@ -87,7 +105,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{
return await HandleAddUsenetErrors(async asQueued =>
{
var result = await GetClient().Usenet.AddLinkAsync(nzbLink, as_queued: asQueued);
var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Usenet.AddLinkAsync(nzbLink, as_queued: asQueued);
return result.Data!.Hash!;
});
@ -97,7 +115,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{
return await HandleAddUsenetErrors(async asQueued =>
{
var result = await GetClient().Usenet.AddFileAsync(bytes, name: name, as_queued: asQueued);
var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Usenet.AddFileAsync(bytes, name: name, as_queued: asQueued);
return result.Data!.Hash!;
});
@ -145,14 +163,17 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
return;
}
if (torrent.Type == DownloadType.Nzb)
await HandleErrors(async () =>
{
await GetClient().Usenet.ControlAsync(torrent.RdId, "delete");
}
else
{
await GetClient().Torrents.ControlAsync(torrent.RdId, "delete");
}
if (torrent.Type == DownloadType.Nzb)
{
await GetClient().Usenet.ControlAsync(torrent.RdId, "delete");
}
else
{
await GetClient().Torrents.ControlByIdAsync(Int32.Parse(torrent.RdId), "delete");
}
});
}
public async Task<String> Unrestrict(Torrent torrent, String link)
@ -186,11 +207,11 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
if (torrent.Type == DownloadType.Nzb)
{
result = await GetClient().Usenet.RequestDownloadAsync(torrentId, fileId, zipped);
result = await HandleErrors(() => GetClient().Usenet.RequestDownloadAsync(torrentId, fileId, zipped));
}
else
{
result = await GetClient().Torrents.RequestDownloadAsync(torrentId, fileId, zipped);
result = await HandleErrors(() => GetClient().Torrents.RequestDownloadAsync(torrentId, fileId, zipped));
}
if (result.Error != null)
@ -304,14 +325,18 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
return null;
}
var usenets = await GetClient().Usenet.GetCurrentAsync(true);
var usenets = await HandleErrors(() => GetClient().Usenet.GetCurrentAsync(true));
var usenet = usenets?.FirstOrDefault(m => m.Hash == torrent.RdId);
id = (Int32?)usenet?.Id;
}
else
{
var torrentId = await GetClient().Torrents.GetHashInfoAsync(torrent.Hash, true);
id = torrentId?.Id;
if (torrent.RdId == null)
{
return null;
}
id = Int32.Parse(torrent.RdId);
}
if (id == null)
@ -321,7 +346,7 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
var downloadableFiles = torrent.Files.Where(file => fileFilter.IsDownloadable(torrent, file.Path, file.Bytes)).ToList();
if (downloadableFiles.Count == torrent.Files.Count && torrent.DownloadClient != Data.Enums.DownloadClient.Symlink && Settings.Get.Provider.PreferZippedDownloads)
if (downloadableFiles.Count == torrent.Files.Count && torrent.DownloadClient != Data.Enums.DownloadClient.Symlink && settings.Current.Provider.PreferZippedDownloads)
{
logger.LogDebug("Downloading files from TorBox as a zip.");
@ -354,19 +379,19 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
return Task.FromResult(download.FileName);
}
protected virtual ITorBoxNetClient GetClient()
protected virtual ITorBoxNetClient GetClient(String clientId = DiConfig.TORBOX_CLIENT)
{
try
{
var apiKey = Settings.Get.Provider.ApiKey;
var apiKey = settings.Current.Provider.ApiKey;
if (String.IsNullOrWhiteSpace(apiKey))
{
throw new("TorBox API Key not set in the settings");
}
var httpClient = httpClientFactory.CreateClient(DiConfig.TORBOX_CLIENT);
var torBoxNetClient = new TorBoxNetClient(null, httpClient);
var httpClient = httpClientFactory.CreateClient(clientId);
var torBoxNetClient = new TorBoxNetClient(null, httpClient, retryCount: 5);
torBoxNetClient.UseApiAuthentication(apiKey);
// Get the server time to fix up the timezones on results
@ -403,39 +428,39 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
protected virtual async Task<IEnumerable<TorrentInfoResult>?> GetCurrentTorrents()
{
return await GetClient().Torrents.GetCurrentAsync(true);
return await HandleErrors(() => GetClient().Torrents.GetCurrentAsync(true));
}
protected virtual async Task<IEnumerable<TorrentInfoResult>?> GetQueuedTorrents()
{
return await GetClient().Torrents.GetQueuedAsync(true);
return await HandleErrors(() => GetClient().Torrents.GetQueuedAsync(true));
}
protected virtual async Task<IEnumerable<UsenetInfoResult>?> GetCurrentUsenet()
{
return await GetClient().Usenet.GetCurrentAsync(true);
return await HandleErrors(() => GetClient().Usenet.GetCurrentAsync(true));
}
protected virtual async Task<IEnumerable<UsenetInfoResult>?> GetQueuedUsenet()
{
return await GetClient().Usenet.GetQueuedAsync(true);
return await HandleErrors(() => GetClient().Usenet.GetQueuedAsync(true));
}
protected virtual async Task<Response<List<AvailableTorrent?>>> GetTorrentAvailability(String hash)
{
return await GetClient().Torrents.GetAvailabilityAsync(hash, true);
return await HandleErrors(() => GetClient().Torrents.GetAvailabilityAsync(hash, true));
}
protected virtual async Task<Response<List<AvailableUsenet?>>> GetUsenetAvailability(String hash)
{
return await GetClient().Usenet.GetAvailabilityAsync(hash, true);
return await HandleErrors(() => GetClient().Usenet.GetAvailabilityAsync(hash, true));
}
private DebridClientTorrent Map(TorrentInfoResult torrent)
{
return new()
{
Id = torrent.Hash,
Id = torrent.Id.ToString(),
Filename = torrent.Name,
OriginalFilename = torrent.Name,
Hash = torrent.Hash,
@ -493,11 +518,11 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
};
}
private async Task<String> HandleAddTorrentErrors(Func<Boolean, Task<String>> action)
private async Task<T> HandleErrors<T>(Func<Task<T>> action)
{
try
{
return await action(false);
return await action();
}
catch (RateLimitException)
{
@ -507,21 +532,25 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{
throw rateLimitException;
}
catch (TorBoxException ex) when (ex.Error.Equals("active_limit", StringComparison.OrdinalIgnoreCase))
catch (TorBoxException ex) when (IsRateLimit(ex))
{
coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2));
throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2));
}
catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase))
{
coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2));
throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2));
}
}
private async Task<String> HandleAddUsenetErrors(Func<Boolean, Task<String>> action)
private async Task HandleErrors(Func<Task> action)
{
try
{
return await action(false);
await action();
}
catch (RateLimitException)
{
@ -531,16 +560,36 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{
throw rateLimitException;
}
catch (TorBoxException ex) when (ex.Error.Equals("active_limit", StringComparison.OrdinalIgnoreCase))
catch (TorBoxException ex) when (IsRateLimit(ex))
{
coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2));
throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2));
}
catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase))
{
coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2));
throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2));
}
}
private async Task<String> HandleAddTorrentErrors(Func<Boolean, Task<String>> action)
{
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));
}
private DateTimeOffset? ChangeTimeZone(DateTimeOffset? dateTimeOffset)
{
if (_offset == null)
@ -553,26 +602,29 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
private async Task<DebridClientTorrent?> GetInfo(String id, DownloadType type)
{
if (type == DownloadType.Nzb)
return await HandleErrors(async () =>
{
var usenet = await GetClient().Usenet.GetHashInfoAsync(id, true);
if (usenet != null)
if (type == DownloadType.Nzb)
{
return Map(usenet);
}
}
else
{
var result = await GetClient().Torrents.GetHashInfoAsync(id, true);
var usenet = await GetClient().Usenet.GetHashInfoAsync(id, true);
if (result != null)
if (usenet != null)
{
return Map(usenet);
}
}
else
{
return Map(result);
}
}
var result = await GetClient().Torrents.GetIdInfoAsync(Int32.Parse(id), true);
return null;
if (result != null)
{
return Map(result);
}
}
return null;
});
}
public static void MoveHashDirContents(String extractPath, Torrent torrent)
@ -617,10 +669,16 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
{
if (!String.IsNullOrWhiteSpace(status))
{
logger.LogInformation("TorBoxDebridClient encountered an unmapped status: {Status} for torrent {TorrentName}", status, torrent.RdName);
logger.LogInformation("TorBoxDebridClient encountered an unmapped status: {Status} for torrent {TorrentName} with previous status {PreviousStatus}",
status,
torrent.RdName,
torrent.RdStatus);
}
return torrent.RdStatus ?? TorrentStatus.Processing;
// Once TorBox has acknowledged the torrent, an unknown provider-side status should not fall all the way back to
// the local queue state. Treat it as provider-side processing so the UI does not incorrectly show
// "Not Yet Added to Provider" while TorBox is already handling it.
return torrent.RdStatus is null or TorrentStatus.Queued ? TorrentStatus.Processing : torrent.RdStatus.Value;
}
private void Log(String message, Torrent? torrent = null)

View file

@ -21,9 +21,9 @@ public class Downloads(DownloadData downloadData) : IDownloads
return await downloadData.Get(torrentId, path);
}
public async Task<Download> Add(Guid torrentId, DownloadInfo downloadInfo)
public async Task<DownloadAddResult> TryAddForTorrent(Guid torrentId, DownloadInfo downloadInfo)
{
return await downloadData.Add(torrentId, downloadInfo);
return await downloadData.TryAddForTorrent(torrentId, downloadInfo);
}
public async Task UpdateUnrestrictedLink(Guid downloadId, String unrestrictedLink)
@ -86,8 +86,8 @@ public class Downloads(DownloadData downloadData) : IDownloads
await downloadData.DeleteForTorrent(torrentId);
}
public async Task Reset(Guid downloadId)
public async Task Reset(Guid downloadId, DateTimeOffset? downloadQueued = null)
{
await downloadData.Reset(downloadId);
await downloadData.Reset(downloadId, downloadQueued);
}
}

View file

@ -1,3 +1,4 @@
using RdtClient.Data.Data;
using RdtClient.Data.Models.Data;
namespace RdtClient.Service.Services;
@ -7,7 +8,7 @@ public interface IDownloads
Task<List<Download>> GetForTorrent(Guid torrentId);
Task<Download?> GetById(Guid downloadId);
Task<Download?> Get(Guid torrentId, String path);
Task<Download> Add(Guid torrentId, DownloadInfo downloadInfo);
Task<DownloadAddResult> TryAddForTorrent(Guid torrentId, DownloadInfo downloadInfo);
Task UpdateUnrestrictedLink(Guid downloadId, String unrestrictedLink);
Task UpdateFileName(Guid downloadId, String fileName);
Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime);
@ -20,5 +21,5 @@ public interface IDownloads
Task UpdateRetryCount(Guid downloadId, Int32 retryCount);
Task UpdateRemoteId(Guid downloadId, String remoteId);
Task DeleteForTorrent(Guid torrentId);
Task Reset(Guid downloadId);
Task Reset(Guid downloadId, DateTimeOffset? downloadQueued = null);
}

View file

@ -5,7 +5,7 @@ using RdtClient.Data.Models.QBittorrent;
namespace RdtClient.Service.Services;
public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authentication authentication, Torrents torrents, Downloads downloads)
public class QBittorrent(ILogger<QBittorrent> logger, ISettings settings, Authentication authentication, Torrents torrents, Downloads downloads, ITorrentRunnerState runnerState)
{
public async Task<Boolean> AuthLogin(String userName, String password)
{
@ -168,7 +168,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
WebUiUsername = ""
};
var savePath = Settings.AppDefaultSavePath;
var savePath = settings.DefaultSavePath;
preferences.SavePath = savePath;
preferences.TempPath = $"{savePath}temp{Path.DirectorySeparatorChar}";
@ -183,9 +183,9 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
return preferences;
}
public async Task<IList<TorrentInfo>> TorrentInfo()
public virtual async Task<IList<TorrentInfo>> TorrentInfo()
{
var savePath = Settings.AppDefaultSavePath;
var savePath = settings.DefaultSavePath;
var results = new List<TorrentInfo>();
@ -214,7 +214,17 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
}
else
{
torrentPath = Path.Combine(downloadPath, torrent.RdName) + Path.DirectorySeparatorChar;
var existingContentPath = GetExistingContentPath(downloadPath, torrent);
if (!String.IsNullOrWhiteSpace(existingContentPath))
{
torrentPath = existingContentPath;
}
else
{
var contentPathName = GetContentPathName(torrent);
torrentPath = Path.Combine(downloadPath, contentPathName) + Path.DirectorySeparatorChar;
}
}
}
@ -328,10 +338,164 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
return results;
}
private static String GetContentPathName(Torrent torrent)
{
if (String.IsNullOrWhiteSpace(torrent.RdName))
{
return torrent.RdName ?? String.Empty;
}
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();
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))
{
return selectedFileName;
}
}
return torrent.RdName;
}
private static String? GetExistingContentPath(String downloadPath, Torrent torrent)
{
if (!torrent.Completed.HasValue || !Directory.Exists(downloadPath))
{
return null;
}
var selectedFilePaths = torrent.Files
.Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path))
.Select(m => NormalizeRelativePath(m.Path!))
.Where(m => !String.IsNullOrWhiteSpace(m))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var downloadFileNames = torrent.Downloads
.Select(m => m.FileName)
.Where(m => !String.IsNullOrWhiteSpace(m))
.Select(m => Path.GetFileName(m!))
.Where(m => !String.IsNullOrWhiteSpace(m))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (selectedFilePaths.Count == 0 && downloadFileNames.Count == 0)
{
return null;
}
foreach (var candidateRoot in GetCandidateContentRoots(downloadPath, torrent, selectedFilePaths, downloadFileNames))
{
if (IsMatchingContentRoot(candidateRoot, selectedFilePaths, downloadFileNames))
{
return candidateRoot + Path.DirectorySeparatorChar;
}
}
return null;
}
private static IEnumerable<String> GetCandidateContentRoots(String downloadPath,
Torrent torrent,
IEnumerable<String> selectedFilePaths,
IEnumerable<String> downloadFileNames)
{
var yielded = new HashSet<String>(StringComparer.OrdinalIgnoreCase);
void AddCandidate(ICollection<String> candidates, String? name)
{
if (!String.IsNullOrWhiteSpace(name))
{
candidates.Add(Path.Combine(downloadPath, name));
}
}
var directCandidates = new List<String>();
AddCandidate(directCandidates, torrent.RdName);
foreach (var fileName in downloadFileNames)
{
AddCandidate(directCandidates, fileName);
}
foreach (var selectedFilePath in selectedFilePaths)
{
AddCandidate(directCandidates, Path.GetFileName(selectedFilePath));
AddCandidate(directCandidates, GetFirstPathComponent(selectedFilePath));
}
foreach (var candidate in directCandidates)
{
if (Directory.Exists(candidate) && yielded.Add(candidate))
{
yield return candidate;
}
}
foreach (var directory in Directory.EnumerateDirectories(downloadPath))
{
if (yielded.Add(directory))
{
yield return directory;
}
}
}
private static Boolean IsMatchingContentRoot(String candidateRoot,
IEnumerable<String> selectedFilePaths,
IEnumerable<String> downloadFileNames)
{
foreach (var selectedFilePath in selectedFilePaths)
{
if (File.Exists(Path.Combine(candidateRoot, selectedFilePath)))
{
return true;
}
}
foreach (var fileName in downloadFileNames)
{
if (File.Exists(Path.Combine(candidateRoot, fileName)))
{
return true;
}
}
return false;
}
private static String NormalizeRelativePath(String path)
{
return path.Trim('/').Trim('\\').Replace('\\', Path.DirectorySeparatorChar);
}
private static String GetFirstPathComponent(String path)
{
var separatorIndex = path.IndexOfAny(['/', '\\']);
return separatorIndex < 0 ? path : path[..separatorIndex];
}
public async Task<IList<TorrentFileItem>?> TorrentFileContents(String hash)
{
var results = new List<TorrentFileItem>();
var torrent = await torrents.GetByHash(hash);
if (torrent == null || torrent.Type != DownloadType.Torrent)
@ -339,22 +503,24 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
return null;
}
foreach (var file in torrent.Files.Where(m => m.Selected))
{
var result = new TorrentFileItem
{
Name = file.Path
};
var progress = torrent.Completed.HasValue || torrent.RdStatus == TorrentStatus.Finished ? 1f : 0f;
results.Add(result);
}
return results;
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();
}
public async Task<TorrentProperties?> TorrentProperties(String hash)
{
var savePath = Settings.AppDefaultSavePath;
var savePath = settings.DefaultSavePath;
var torrent = await torrents.GetByHash(hash);
@ -385,6 +551,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
AdditionDate = torrent.Added.ToUnixTimeSeconds(),
Comment = "RealDebridClient <https://github.com/rogerfar/rdt-client>",
CompletionDate = torrent.Completed?.ToUnixTimeSeconds() ?? -1,
IsPrivate = false,
CreatedBy = "RealDebridClient <https://github.com/rogerfar/rdt-client>",
CreationDate = torrent.Added.ToUnixTimeSeconds(),
DlLimit = -1,
@ -420,6 +587,11 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
return result;
}
public async Task TorrentsFilePrio(String hash, IReadOnlyCollection<Int32> fileIds, Int32 priority)
{
await torrents.UpdateFileSelection(hash, fileIds, priority > 0);
}
public async Task TorrentsDelete(String hash, Boolean deleteFiles)
{
if (deleteFiles)
@ -438,7 +610,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
return;
}
switch (Settings.Get.Integrations.Default.FinishedAction)
switch (settings.Current.Integrations.Default.FinishedAction)
{
case TorrentFinishedAction.RemoveAllTorrents:
logger.LogDebug("Removing torrents from debrid provider and RDT-Client, no files");
@ -466,54 +638,54 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
}
}
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}");
var torrent = new Torrent
{
Category = category,
DownloadClient = Settings.Get.DownloadClient.Client,
HostDownloadAction = Settings.Get.Integrations.Default.HostDownloadAction,
FinishedActionDelay = Settings.Get.Integrations.Default.FinishedActionDelay,
DownloadAction = Settings.Get.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
DownloadClient = settings.Current.DownloadClient.Client,
HostDownloadAction = settings.Current.Integrations.Default.HostDownloadAction,
FinishedActionDelay = settings.Current.Integrations.Default.FinishedActionDelay,
DownloadAction = settings.Current.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
FinishedAction = TorrentFinishedAction.None,
DownloadMinSize = Settings.Get.Integrations.Default.MinFileSize,
IncludeRegex = Settings.Get.Integrations.Default.IncludeRegex,
ExcludeRegex = Settings.Get.Integrations.Default.ExcludeRegex,
TorrentRetryAttempts = Settings.Get.Integrations.Default.TorrentRetryAttempts,
DownloadRetryAttempts = Settings.Get.Integrations.Default.DownloadRetryAttempts,
DeleteOnError = Settings.Get.Integrations.Default.DeleteOnError,
Lifetime = Settings.Get.Integrations.Default.TorrentLifetime,
Priority = priority ?? (Settings.Get.Integrations.Default.Priority > 0 ? Settings.Get.Integrations.Default.Priority : null)
DownloadMinSize = settings.Current.Integrations.Default.MinFileSize,
IncludeRegex = settings.Current.Integrations.Default.IncludeRegex,
ExcludeRegex = settings.Current.Integrations.Default.ExcludeRegex,
TorrentRetryAttempts = settings.Current.Integrations.Default.TorrentRetryAttempts,
DownloadRetryAttempts = settings.Current.Integrations.Default.DownloadRetryAttempts,
DeleteOnError = settings.Current.Integrations.Default.DeleteOnError,
Lifetime = settings.Current.Integrations.Default.TorrentLifetime,
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}");
var torrent = new Torrent
{
Category = category,
DownloadClient = Settings.Get.DownloadClient.Client,
HostDownloadAction = Settings.Get.Integrations.Default.HostDownloadAction,
FinishedActionDelay = Settings.Get.Integrations.Default.FinishedActionDelay,
DownloadAction = Settings.Get.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
DownloadClient = settings.Current.DownloadClient.Client,
HostDownloadAction = settings.Current.Integrations.Default.HostDownloadAction,
FinishedActionDelay = settings.Current.Integrations.Default.FinishedActionDelay,
DownloadAction = settings.Current.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
FinishedAction = TorrentFinishedAction.None,
DownloadMinSize = Settings.Get.Integrations.Default.MinFileSize,
IncludeRegex = Settings.Get.Integrations.Default.IncludeRegex,
ExcludeRegex = Settings.Get.Integrations.Default.ExcludeRegex,
TorrentRetryAttempts = Settings.Get.Integrations.Default.TorrentRetryAttempts,
DownloadRetryAttempts = Settings.Get.Integrations.Default.DownloadRetryAttempts,
DeleteOnError = Settings.Get.Integrations.Default.DeleteOnError,
Lifetime = Settings.Get.Integrations.Default.TorrentLifetime,
Priority = priority ?? (Settings.Get.Integrations.Default.Priority > 0 ? Settings.Get.Integrations.Default.Priority : null)
DownloadMinSize = settings.Current.Integrations.Default.MinFileSize,
IncludeRegex = settings.Current.Integrations.Default.IncludeRegex,
ExcludeRegex = settings.Current.Integrations.Default.ExcludeRegex,
TorrentRetryAttempts = settings.Current.Integrations.Default.TorrentRetryAttempts,
DownloadRetryAttempts = settings.Current.Integrations.Default.DownloadRetryAttempts,
DeleteOnError = settings.Current.Integrations.Default.DeleteOnError,
Lifetime = settings.Current.Integrations.Default.TorrentLifetime,
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)
@ -529,7 +701,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
.Select(m => m.Category!.ToLower())
.ToList();
var categoryList = (Settings.Get.General.Categories ?? "")
var categoryList = (settings.Current.General.Categories ?? "")
.Split(",", StringSplitOptions.RemoveEmptyEntries)
.Distinct(StringComparer.CurrentCultureIgnoreCase)
.Select(m => m.Trim())
@ -546,7 +718,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
m => new TorrentCategory
{
Name = m,
SavePath = Path.Combine(Settings.AppDefaultSavePath, m)
SavePath = Path.Combine(settings.DefaultSavePath, m)
});
}
@ -562,7 +734,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
category = category.Trim();
var categoriesSetting = Settings.Get.General.Categories;
var categoriesSetting = settings.Current.General.Categories;
var categoryList = (categoriesSetting ?? "")
.Split(",", StringSplitOptions.RemoveEmptyEntries)
@ -589,7 +761,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
category = category.Trim();
var categoriesSetting = Settings.Get.General.Categories;
var categoriesSetting = settings.Current.General.Categories;
var categoryList = (categoriesSetting ?? "")
.Split(",", StringSplitOptions.RemoveEmptyEntries)
@ -629,7 +801,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
foreach (var download in downloadsForTorrent)
{
if (TorrentRunner.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient))
if (runnerState.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient))
{
await downloadClient.Pause();
}
@ -649,7 +821,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
foreach (var download in downloadsForTorrent)
{
if (TorrentRunner.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient))
if (runnerState.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient))
{
await downloadClient.Resume();
}
@ -662,7 +834,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
var categories = await TorrentsCategories();
var activeDownloads = TorrentRunner.ActiveDownloadClients.Sum(m => m.Value.Speed);
var activeDownloads = runnerState.ActiveDownloadClients.Sum(m => m.Value.Speed);
return new()
{
@ -680,16 +852,37 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
};
}
public static TransferInfo TransferInfo()
public virtual TransferInfo TransferInfo()
{
var activeDownloads = TorrentRunner.ActiveDownloadClients.Sum(m => m.Value.Speed);
var activeDownloads = runnerState.ActiveDownloadClients.Sum(m => m.Value.Speed);
return new()
{
ConnectionStatus = "connected",
DlInfoData = DownloadClient.GetTotalBytesDownloadedThisSession(),
DlInfoSpeed = activeDownloads,
DlRateLimit = Settings.Get.DownloadClient.MaxSpeed
DlRateLimit = settings.Current.DownloadClient.MaxSpeed
};
}
public async Task<List<Tracker>> TorrentsTrackers(String hash)
{
var torrent = await torrents.GetByHash(hash);
if (torrent == null || torrent.Type != DownloadType.Torrent)
{
return [];
}
return
[
new()
{
Url = $"http://{torrent.RdHost ?? torrent.ClientKind.ToString()}/**".ToLower(),
Status = "Working",
NumPeers = torrent.RdSeeders ?? 1,
Msg = $"Fake Tracker for {torrent.ClientKind}"
}
];
}
}

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.SignalR;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers;
namespace RdtClient.Service.Services;
@ -9,72 +10,7 @@ public class RemoteService(IHubContext<RdtHub> hub, Torrents torrents)
{
var allTorrents = await torrents.Get();
var torrentDtos = allTorrents.Select(torrent => new TorrentDto
{
TorrentId = torrent.TorrentId,
Hash = torrent.Hash,
Category = torrent.Category,
DownloadAction = torrent.DownloadAction,
FinishedAction = torrent.FinishedAction,
FinishedActionDelay = torrent.FinishedActionDelay,
HostDownloadAction = torrent.HostDownloadAction,
DownloadMinSize = torrent.DownloadMinSize,
IncludeRegex = torrent.IncludeRegex,
ExcludeRegex = torrent.ExcludeRegex,
DownloadManualFiles = torrent.DownloadManualFiles,
DownloadClient = torrent.DownloadClient,
Added = torrent.Added,
FilesSelected = torrent.FilesSelected,
Completed = torrent.Completed,
Type = torrent.Type,
IsFile = torrent.IsFile,
Priority = torrent.Priority,
RetryCount = torrent.RetryCount,
DownloadRetryAttempts = torrent.DownloadRetryAttempts,
TorrentRetryAttempts = torrent.TorrentRetryAttempts,
DeleteOnError = torrent.DeleteOnError,
Lifetime = torrent.Lifetime,
Error = torrent.Error,
RdId = torrent.RdId,
RdName = torrent.RdName,
RdSize = torrent.RdSize,
RdHost = torrent.RdHost,
RdSplit = torrent.RdSplit,
RdProgress = torrent.RdProgress,
RdStatus = torrent.RdStatus,
RdStatusRaw = torrent.RdStatusRaw,
RdAdded = torrent.RdAdded,
RdEnded = torrent.RdEnded,
RdSpeed = torrent.RdSpeed,
RdSeeders = torrent.RdSeeders,
Files = torrent.Files,
Downloads = torrent.Downloads.Select(download =>
{
var (speed, bytesTotal, bytesDone) = torrents.GetDownloadStats(download.DownloadId);
return new DownloadDto
{
DownloadId = download.DownloadId,
TorrentId = download.TorrentId,
Path = download.Path,
Link = download.Link,
Added = download.Added,
DownloadQueued = download.DownloadQueued,
DownloadStarted = download.DownloadStarted,
DownloadFinished = download.DownloadFinished,
UnpackingQueued = download.UnpackingQueued,
UnpackingStarted = download.UnpackingStarted,
UnpackingFinished = download.UnpackingFinished,
Completed = download.Completed,
RetryCount = download.RetryCount,
Error = download.Error,
BytesTotal = bytesTotal,
BytesDone = bytesDone,
Speed = speed
};
})
.ToList()
})
var torrentDtos = allTorrents.Select(torrent => TorrentDtoMapper.ToUpdateDto(torrent, torrents.GetDownloadStats))
.ToList();
await hub.Clients.All.SendCoreAsync("update",

View file

@ -7,7 +7,7 @@ using RdtClient.Service.Helpers;
namespace RdtClient.Service.Services;
public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings appSettings)
public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings appSettings, ISettings settings)
{
public virtual async Task<SabnzbdQueue> GetQueue()
{
@ -87,7 +87,7 @@ public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings app
var allTorrents = await torrents.Get();
var completedTorrents = allTorrents.Where(t => t.Type == DownloadType.Nzb && t.Completed != null).ToList();
var savePath = Settings.AppDefaultSavePath;
var savePath = settings.DefaultSavePath;
var history = new SabnzbdHistory
{
@ -130,19 +130,19 @@ public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings app
var torrent = new Torrent
{
Category = category,
DownloadClient = Settings.Get.DownloadClient.Client,
HostDownloadAction = Settings.Get.Integrations.Default.HostDownloadAction,
FinishedActionDelay = Settings.Get.Integrations.Default.FinishedActionDelay,
DownloadAction = Settings.Get.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
DownloadClient = settings.Current.DownloadClient.Client,
HostDownloadAction = settings.Current.Integrations.Default.HostDownloadAction,
FinishedActionDelay = settings.Current.Integrations.Default.FinishedActionDelay,
DownloadAction = settings.Current.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
FinishedAction = TorrentFinishedAction.None,
DownloadMinSize = Settings.Get.Integrations.Default.MinFileSize,
IncludeRegex = Settings.Get.Integrations.Default.IncludeRegex,
ExcludeRegex = Settings.Get.Integrations.Default.ExcludeRegex,
TorrentRetryAttempts = Settings.Get.Integrations.Default.TorrentRetryAttempts,
DownloadRetryAttempts = Settings.Get.Integrations.Default.DownloadRetryAttempts,
DeleteOnError = Settings.Get.Integrations.Default.DeleteOnError,
Lifetime = Settings.Get.Integrations.Default.TorrentLifetime,
Priority = (priority ?? Settings.Get.Integrations.Default.Priority) > 0 ? 1 : null
DownloadMinSize = settings.Current.Integrations.Default.MinFileSize,
IncludeRegex = settings.Current.Integrations.Default.IncludeRegex,
ExcludeRegex = settings.Current.Integrations.Default.ExcludeRegex,
TorrentRetryAttempts = settings.Current.Integrations.Default.TorrentRetryAttempts,
DownloadRetryAttempts = settings.Current.Integrations.Default.DownloadRetryAttempts,
DeleteOnError = settings.Current.Integrations.Default.DeleteOnError,
Lifetime = settings.Current.Integrations.Default.TorrentLifetime,
Priority = (priority ?? settings.Current.Integrations.Default.Priority) > 0 ? 1 : null
};
var result = await torrents.AddNzbFileToDebridQueue(fileBytes, fileName, torrent);
@ -157,19 +157,19 @@ public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings app
var torrent = new Torrent
{
Category = category,
DownloadClient = Settings.Get.DownloadClient.Client,
HostDownloadAction = Settings.Get.Integrations.Default.HostDownloadAction,
FinishedActionDelay = Settings.Get.Integrations.Default.FinishedActionDelay,
DownloadAction = Settings.Get.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
DownloadClient = settings.Current.DownloadClient.Client,
HostDownloadAction = settings.Current.Integrations.Default.HostDownloadAction,
FinishedActionDelay = settings.Current.Integrations.Default.FinishedActionDelay,
DownloadAction = settings.Current.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
FinishedAction = TorrentFinishedAction.None,
DownloadMinSize = Settings.Get.Integrations.Default.MinFileSize,
IncludeRegex = Settings.Get.Integrations.Default.IncludeRegex,
ExcludeRegex = Settings.Get.Integrations.Default.ExcludeRegex,
TorrentRetryAttempts = Settings.Get.Integrations.Default.TorrentRetryAttempts,
DownloadRetryAttempts = Settings.Get.Integrations.Default.DownloadRetryAttempts,
DeleteOnError = Settings.Get.Integrations.Default.DeleteOnError,
Lifetime = Settings.Get.Integrations.Default.TorrentLifetime,
Priority = priority ?? (Settings.Get.Integrations.Default.Priority > 0 ? Settings.Get.Integrations.Default.Priority : null)
DownloadMinSize = settings.Current.Integrations.Default.MinFileSize,
IncludeRegex = settings.Current.Integrations.Default.IncludeRegex,
ExcludeRegex = settings.Current.Integrations.Default.ExcludeRegex,
TorrentRetryAttempts = settings.Current.Integrations.Default.TorrentRetryAttempts,
DownloadRetryAttempts = settings.Current.Integrations.Default.DownloadRetryAttempts,
DeleteOnError = settings.Current.Integrations.Default.DeleteOnError,
Lifetime = settings.Current.Integrations.Default.TorrentLifetime,
Priority = priority ?? (settings.Current.Integrations.Default.Priority > 0 ? settings.Current.Integrations.Default.Priority : null)
};
var result = await torrents.AddNzbLinkToDebridQueue(url, torrent);
@ -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);
@ -186,21 +186,21 @@ public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings app
return;
}
switch (Settings.Get.Integrations.Default.FinishedAction)
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:
@ -216,7 +216,7 @@ public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings app
public virtual List<String> GetCategories()
{
var categoryList = (Settings.Get.General.Categories ?? "")
var categoryList = (settings.Current.General.Categories ?? "")
.Split(",", StringSplitOptions.RemoveEmptyEntries)
.Select(m => m.Trim())
.Where(m => m != "*")
@ -230,7 +230,7 @@ public class Sabnzbd(ILogger<Sabnzbd> logger, Torrents torrents, AppSettings app
public virtual SabnzbdConfig GetConfig()
{
var savePath = Settings.AppDefaultSavePath;
var savePath = settings.DefaultSavePath;
var categoryList = GetCategories();

View file

@ -1,49 +1,88 @@
using RdtClient.Data.Data;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Internal;
using Microsoft.Extensions.DependencyInjection;
using Serilog.Core;
using Serilog.Events;
namespace RdtClient.Service.Services;
public class Settings(SettingData settingData)
public interface ISettings
{
DbSettings Current { get; }
String DefaultSavePath { get; }
IList<SettingProperty> GetAll();
Task Update(IList<SettingProperty> settings);
Task Update(String settingId, Object? value);
}
public class Settings(IServiceScopeFactory serviceScopeFactory) : ISettings
{
public static readonly LoggingLevelSwitch LoggingLevelSwitch = new(LogEventLevel.Debug);
public static DbSettings Get => SettingData.Get;
public static String AppDefaultSavePath
public DbSettings Current => Get;
public static String AppDefaultSavePath => GetDefaultSavePath(Get);
public String DefaultSavePath => GetDefaultSavePath(Current);
public IList<SettingProperty> GetAll()
{
get
return SettingData.GetAll();
}
private static String GetDefaultSavePath(DbSettings settings)
{
var downloadPath = settings.DownloadClient.MappedPath;
if (String.IsNullOrWhiteSpace(downloadPath))
{
var downloadPath = Get.DownloadClient.MappedPath;
downloadPath = downloadPath.TrimEnd('\\')
.TrimEnd('/');
downloadPath += Path.DirectorySeparatorChar;
return downloadPath;
downloadPath = settings.DownloadClient.DownloadPath;
}
downloadPath = downloadPath.TrimEnd('\\')
.TrimEnd('/');
downloadPath += Path.DirectorySeparatorChar;
return downloadPath;
}
public async Task Update(IList<SettingProperty> settings)
{
using var scope = serviceScopeFactory.CreateScope();
var settingData = scope.ServiceProvider.GetRequiredService<SettingData>();
await settingData.Update(settings);
}
public async Task Update(String settingId, Object? value)
{
using var scope = serviceScopeFactory.CreateScope();
var settingData = scope.ServiceProvider.GetRequiredService<SettingData>();
await settingData.Update(settingId, value);
}
public async Task Seed()
{
using var scope = serviceScopeFactory.CreateScope();
var settingData = scope.ServiceProvider.GetRequiredService<SettingData>();
await settingData.Seed();
}
public async Task ResetCache()
{
using var scope = serviceScopeFactory.CreateScope();
var settingData = scope.ServiceProvider.GetRequiredService<SettingData>();
await settingData.ResetCache();
LoggingLevelSwitch.MinimumLevel = Get.General.LogLevel switch

View file

@ -11,35 +11,40 @@ using RdtClient.Service.Services.Downloaders;
namespace RdtClient.Service.Services;
public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Downloads downloads, RemoteService remoteService, IHttpClientFactory httpClientFactory)
public class TorrentRunner(
ILogger<TorrentRunner> logger,
Torrents torrents,
Downloads downloads,
RemoteService remoteService,
IHttpClientFactory httpClientFactory,
IRateLimitCoordinator coordinator,
ISettings settings,
ITorrentRunnerState runnerState)
{
public static readonly ConcurrentDictionary<Guid, DownloadClient> ActiveDownloadClients = new();
public static readonly ConcurrentDictionary<Guid, UnpackClient> ActiveUnpackClients = new();
public static readonly ITorrentRunnerState SharedState = new TorrentRunnerState();
public static Boolean IsPausedForLowDiskSpace { get; set; }
public static ConcurrentDictionary<Guid, DownloadClient> ActiveDownloadClients => SharedState.ActiveDownloadClients;
public static DateTimeOffset NextDequeueTime { get; private set; } = DateTimeOffset.MinValue;
public static ConcurrentDictionary<Guid, UnpackClient> ActiveUnpackClients => SharedState.ActiveUnpackClients;
private DateTimeOffset? _lastNextAllowedAt;
public static Boolean IsPausedForLowDiskSpace
{
get => SharedState.IsPausedForLowDiskSpace;
set => SharedState.IsPausedForLowDiskSpace = value;
}
public static (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetStats(Guid downloadId)
{
if (ActiveDownloadClients.TryGetValue(downloadId, out var downloadClient))
{
return (downloadClient.Speed, downloadClient.BytesTotal, downloadClient.BytesDone);
}
if (ActiveUnpackClients.TryGetValue(downloadId, out var unpackClient))
{
return (0, 100, unpackClient.Progess);
}
return (0, 0, 0);
return SharedState.GetStats(downloadId);
}
public async Task Initialize()
{
Log("Initializing TorrentRunner");
var settingsCopy = JsonSerializer.Deserialize<DbSettings>(JsonSerializer.Serialize(Settings.Get));
var settingsCopy = JsonSerializer.Deserialize<DbSettings>(JsonSerializer.Serialize(settings.Current));
if (settingsCopy != null)
{
@ -82,28 +87,28 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
public async Task Tick()
{
if (String.IsNullOrWhiteSpace(Settings.Get.Provider.ApiKey))
if (String.IsNullOrWhiteSpace(settings.Current.Provider.ApiKey))
{
Log($"No RealDebridApiKey set in settings");
return;
}
var settingDownloadLimit = Settings.Get.General.DownloadLimit;
var settingDownloadLimit = settings.Current.General.DownloadLimit;
if (settingDownloadLimit < 1)
{
settingDownloadLimit = 1;
}
var settingUnpackLimit = Settings.Get.General.UnpackLimit;
var settingUnpackLimit = settings.Current.General.UnpackLimit;
if (settingUnpackLimit < 0)
{
settingUnpackLimit = 0;
}
var settingDownloadPath = Settings.Get.DownloadClient.DownloadPath;
var settingDownloadPath = settings.Current.DownloadClient.DownloadPath;
if (String.IsNullOrWhiteSpace(settingDownloadPath))
{
@ -115,25 +120,46 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
var sw = new Stopwatch();
sw.Start();
if (!ActiveDownloadClients.IsEmpty || !ActiveUnpackClients.IsEmpty)
var currentNextAllowedAt = coordinator.GetMaxNextAllowedAt();
if (currentNextAllowedAt != _lastNextAllowedAt)
{
Log($"TorrentRunner Tick Start, {ActiveDownloadClients.Count} active downloads, {ActiveUnpackClients.Count} active unpacks");
if (currentNextAllowedAt == null || currentNextAllowedAt <= DateTimeOffset.UtcNow)
{
if (_lastNextAllowedAt > DateTimeOffset.UtcNow)
{
Log("Rate-limit cooldown expired, resuming dequeuing");
await remoteService.UpdateRateLimitStatus(new()
{
NextDequeueTime = null,
SecondsRemaining = 0
});
}
}
_lastNextAllowedAt = currentNextAllowedAt;
}
if (ActiveDownloadClients.Any(m => m.Value.Type == Data.Enums.DownloadClient.Aria2c))
if (!runnerState.ActiveDownloadClients.IsEmpty || !runnerState.ActiveUnpackClients.IsEmpty)
{
Log($"TorrentRunner Tick Start, {runnerState.ActiveDownloadClients.Count} active downloads, {runnerState.ActiveUnpackClients.Count} active unpacks");
}
if (runnerState.ActiveDownloadClients.Any(m => m.Value.Type == Data.Enums.DownloadClient.Aria2c))
{
Log("Updating Aria2 status");
var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
var aria2NetClient = new Aria2NetClient(Settings.Get.DownloadClient.Aria2cUrl, Settings.Get.DownloadClient.Aria2cSecret, httpClient, 1);
var aria2NetClient = new Aria2NetClient(settings.Current.DownloadClient.Aria2cUrl, settings.Current.DownloadClient.Aria2cSecret, httpClient, 1);
var allDownloads = await aria2NetClient.TellAllAsync();
Log($"Found {allDownloads.Count} Aria2 downloads");
foreach (var activeDownload in ActiveDownloadClients)
foreach (var activeDownload in runnerState.ActiveDownloadClients)
{
if (activeDownload.Value.Downloader is Aria2cDownloader aria2Downloader)
{
@ -144,11 +170,11 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
Log("Finished updating Aria2 status");
}
if (ActiveDownloadClients.Any(m => m.Value.Type == Data.Enums.DownloadClient.DownloadStation))
if (runnerState.ActiveDownloadClients.Any(m => m.Value.Type == Data.Enums.DownloadClient.DownloadStation))
{
Log("Updating DownloadStation status");
foreach (var activeDownload in ActiveDownloadClients)
foreach (var activeDownload in runnerState.ActiveDownloadClients)
{
if (activeDownload.Value.Downloader is DownloadStationDownloader downloadStationDownloader)
{
@ -158,7 +184,7 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
}
// Check if any torrents are finished downloading to the host, remove them from the active download list.
var completedActiveDownloads = ActiveDownloadClients.Where(m => m.Value.Finished).ToList();
var completedActiveDownloads = runnerState.ActiveDownloadClients.Where(m => m.Value.Finished).ToList();
if (completedActiveDownloads.Count > 0)
{
@ -170,7 +196,7 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
if (download == null)
{
ActiveDownloadClients.TryRemove(downloadId, out _);
runnerState.ActiveDownloadClients.TryRemove(downloadId, out _);
Log($"Download with ID {downloadId} not found! Removed from download queue");
@ -211,14 +237,14 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
await downloads.UpdateUnpackingQueued(downloadId, DateTimeOffset.UtcNow);
}
ActiveDownloadClients.TryRemove(downloadId, out _);
runnerState.ActiveDownloadClients.TryRemove(downloadId, out _);
Log($"Removed from ActiveDownloadClients", download, download.Torrent);
}
}
// Check if any torrents are finished unpacking, remove them from the active unpack list.
var completedUnpacks = ActiveUnpackClients.Where(m => m.Value.Finished).ToList();
var completedUnpacks = runnerState.ActiveUnpackClients.Where(m => m.Value.Finished).ToList();
if (completedUnpacks.Count > 0)
{
@ -230,7 +256,7 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
if (download == null)
{
ActiveUnpackClients.TryRemove(downloadId, out _);
runnerState.ActiveUnpackClients.TryRemove(downloadId, out _);
Log($"Download with ID {downloadId} not found! Removed from unpack queue");
@ -252,36 +278,33 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
await downloads.UpdateCompleted(downloadId, DateTimeOffset.UtcNow);
ActiveUnpackClients.TryRemove(downloadId, out _);
runnerState.ActiveUnpackClients.TryRemove(downloadId, out _);
Log($"Removed from ActiveUnpackClients", download, download.Torrent);
}
}
var allTorrents = await torrents.Get();
var downloadsById = allTorrents.SelectMany(m => m.Downloads).ToDictionary(m => m.DownloadId, m => m);
// Check for deleted torrents that are stuck in the ActiveDownloads or ActiveUnpacks
foreach (var activeDownload in ActiveDownloadClients)
foreach (var activeDownload in runnerState.ActiveDownloadClients)
{
var download = allTorrents.SelectMany(m => m.Downloads).FirstOrDefault(m => m.DownloadId == activeDownload.Key);
if (download == null)
if (!downloadsById.ContainsKey(activeDownload.Key))
{
await activeDownload.Value.Cancel();
ActiveDownloadClients.TryRemove(activeDownload.Key, out _);
runnerState.ActiveDownloadClients.TryRemove(activeDownload.Key, out _);
break;
}
}
foreach (var activeUnpacks in ActiveUnpackClients)
foreach (var activeUnpacks in runnerState.ActiveUnpackClients)
{
var download = allTorrents.SelectMany(m => m.Downloads).FirstOrDefault(m => m.DownloadId == activeUnpacks.Key);
if (download == null)
if (!downloadsById.ContainsKey(activeUnpacks.Key))
{
activeUnpacks.Value.Cancel();
ActiveUnpackClients.TryRemove(activeUnpacks.Key, out _);
runnerState.ActiveUnpackClients.TryRemove(activeUnpacks.Key, out _);
break;
}
@ -344,31 +367,24 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
}
// Process torrents in DebridQueue
var torrentsToAddToProvider = allTorrents.Where(m => 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)
{
if (DateTimeOffset.Now < NextDequeueTime)
var nextAllowedAt = coordinator.GetMaxNextAllowedAt();
if (nextAllowedAt > DateTimeOffset.UtcNow)
{
logger.LogDebug($"Dequeuing torrents is paused until {NextDequeueTime}, {NextDequeueTime - DateTimeOffset.Now} remaining");
logger.LogDebug($"Dequeuing torrents is paused until {nextAllowedAt}, {nextAllowedAt - DateTimeOffset.Now} remaining");
}
else
{
if (NextDequeueTime != DateTimeOffset.MinValue)
{
NextDequeueTime = DateTimeOffset.MinValue;
await remoteService.UpdateRateLimitStatus(new()
{
NextDequeueTime = null,
SecondsRemaining = 0
});
}
var downloadingTorrentsCount = allTorrents.Count(m => m.RdStatus is not (TorrentStatus.Queued or TorrentStatus.Finished or TorrentStatus.Error));
var maxParallelDownloads = Settings.Get.Provider.MaxParallelDownloads;
var maxParallelDownloads = settings.Current.Provider.MaxParallelDownloads;
logger.LogDebug("Currently downloading {downloadingTorrentCount}/{maxParallelDownloads} torrents, {queuedCount} queued.",
downloadingTorrentsCount,
@ -463,31 +479,35 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
{
// 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();
Log($"Currently {queuedDownloads.Count} queued downloads and {ActiveDownloadClients.Count} total active downloads", torrent);
Log($"Currently {queuedDownloads.Count} queued downloads and {runnerState.ActiveDownloadClients.Count} total active downloads", torrent);
foreach (var download in queuedDownloads)
{
Log($"Processing to download", download, torrent);
if (ActiveDownloadClients.Count >= settingDownloadLimit && torrent.DownloadClient != Data.Enums.DownloadClient.Symlink)
if (runnerState.ActiveDownloadClients.Count >= settingDownloadLimit && torrent.DownloadClient != Data.Enums.DownloadClient.Symlink)
{
Log($"Not starting download because there are already the max number of downloads active", download, torrent);
return;
}
if (IsPausedForLowDiskSpace && torrent.DownloadClient == Data.Enums.DownloadClient.Bezzad)
if (runnerState.IsPausedForLowDiskSpace && torrent.DownloadClient == Data.Enums.DownloadClient.Bezzad)
{
logger.LogInformation($"Not starting Bezzad download because of low disk space {download.ToLog()} {torrent.ToLog()}");
return;
}
if (ActiveDownloadClients.ContainsKey(download.DownloadId))
if (runnerState.ActiveDownloadClients.ContainsKey(download.DownloadId))
{
Log($"Not starting download because this download is already active", download, torrent);
@ -514,10 +534,24 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
{
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;
}
@ -539,7 +573,7 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
// Start the download process
var downloadClient = new DownloadClient(download, torrent, downloadPath, torrent.Category);
if (ActiveDownloadClients.TryAdd(download.DownloadId, downloadClient))
if (runnerState.ActiveDownloadClients.TryAdd(download.DownloadId, downloadClient))
{
Log($"Starting download", download, torrent);
@ -559,7 +593,7 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
await downloads.UpdateRemoteId(download.DownloadId, remoteId);
}
if (IsPausedForLowDiskSpace && downloadClient.Type == Data.Enums.DownloadClient.Bezzad)
if (runnerState.IsPausedForLowDiskSpace && downloadClient.Type == Data.Enums.DownloadClient.Bezzad)
{
logger.LogInformation($"Pausing new Bezzad download due to low disk space {download.ToLog()} {torrent.ToLog()}");
await downloadClient.Pause();
@ -619,14 +653,14 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
}
// Check if we have reached the download limit, if so queue the download, but don't start it.
if (ActiveUnpackClients.Count >= settingUnpackLimit)
if (runnerState.ActiveUnpackClients.Count >= settingUnpackLimit)
{
Log($"Not starting unpack because there are already the max number of unpacks active", download, torrent);
continue;
}
if (ActiveUnpackClients.ContainsKey(download.DownloadId))
if (runnerState.ActiveUnpackClients.ContainsKey(download.DownloadId))
{
Log($"Not starting unpack because this download is already active", download, torrent);
@ -648,7 +682,7 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
// Start the unpacking process
var unpackClient = new UnpackClient(download, downloadPath);
if (ActiveUnpackClients.TryAdd(download.DownloadId, unpackClient))
if (runnerState.ActiveUnpackClients.TryAdd(download.DownloadId, unpackClient))
{
Log($"Starting unpack", download, torrent);
@ -706,8 +740,8 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
var completePerc = 0;
var totalDownloadBytes = torrent.Downloads.Sum(m => GetStats(m.DownloadId).BytesTotal);
var totalDoneBytes = torrent.Downloads.Sum(m => GetStats(m.DownloadId).BytesDone);
var totalDownloadBytes = torrent.Downloads.Sum(m => runnerState.GetStats(m.DownloadId).BytesTotal);
var totalDoneBytes = torrent.Downloads.Sum(m => runnerState.GetStats(m.DownloadId).BytesDone);
if (totalDownloadBytes > 0)
{
@ -752,17 +786,35 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
public async Task SetRateLimit(TimeSpan retryAfter, String message)
{
NextDequeueTime = DateTimeOffset.Now.Add(retryAfter);
coordinator.UpdateCooldown("General", retryAfter);
var nextDequeueTime = coordinator.GetMaxNextAllowedAt();
var now = DateTimeOffset.UtcNow;
var secondsRemaining = nextDequeueTime.HasValue ? (nextDequeueTime.Value - now).TotalSeconds : 0;
Log($"Rate-limit reached, pausing dequeuing for {retryAfter.TotalMinutes} minutes (until {NextDequeueTime}): {message}");
Log($"Rate-limit reached, pausing dequeuing for {retryAfter.TotalMinutes} minutes (until {nextDequeueTime}): {message}");
_lastNextAllowedAt = nextDequeueTime;
await remoteService.UpdateRateLimitStatus(new()
{
NextDequeueTime = NextDequeueTime,
SecondsRemaining = retryAfter.TotalSeconds
NextDequeueTime = nextDequeueTime,
SecondsRemaining = secondsRemaining
});
}
private static TimeSpan GetDownloadLinkRetryDelay(Int32 retryCount)
{
var seconds = retryCount switch
{
<= 1 => 15,
2 => 30,
3 => 60,
_ => 120
};
return TimeSpan.FromSeconds(seconds);
}
private void Log(String message, Download? download, Torrent? torrent)
{
if (download != null)

View file

@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace RdtClient.Service.Services;
public interface ITorrentRunnerState
{
ConcurrentDictionary<Guid, DownloadClient> ActiveDownloadClients { get; }
ConcurrentDictionary<Guid, UnpackClient> ActiveUnpackClients { get; }
Boolean IsPausedForLowDiskSpace { get; set; }
(Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetStats(Guid downloadId);
void Clear();
}
public class TorrentRunnerState : ITorrentRunnerState
{
public ConcurrentDictionary<Guid, DownloadClient> ActiveDownloadClients { get; } = new();
public ConcurrentDictionary<Guid, UnpackClient> ActiveUnpackClients { get; } = new();
public Boolean IsPausedForLowDiskSpace { get; set; }
public (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetStats(Guid downloadId)
{
if (ActiveDownloadClients.TryGetValue(downloadId, out var downloadClient))
{
return (downloadClient.Speed, downloadClient.BytesTotal, downloadClient.BytesDone);
}
if (ActiveUnpackClients.TryGetValue(downloadId, out var unpackClient))
{
return (0, 100, unpackClient.Progess);
}
return (0, 0, 0);
}
public void Clear()
{
ActiveDownloadClients.Clear();
ActiveUnpackClients.Clear();
IsPausedForLowDiskSpace = false;
}
}

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.IO.Abstractions;
using System.Security.Cryptography;
using System.Text;
@ -32,22 +32,24 @@ public class Torrents(
PremiumizeDebridClient premiumizeDebridClient,
RealDebridDebridClient realDebridDebridClient,
DebridLinkClient debridLinkClient,
TorBoxDebridClient torBoxDebridClient)
TorBoxDebridClient torBoxDebridClient,
ISettings settings,
ITorrentRunnerState runnerState)
{
private static readonly SemaphoreSlim RealDebridUpdateLock = new(1, 1);
private static readonly SemaphoreSlim TorrentResetLock = new(1, 1);
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles
};
private static readonly SemaphoreSlim TorrentResetLock = new(1, 1);
private IDebridClient DebridClient
{
get
{
return Settings.Get.Provider.Provider switch
return settings.Current.Provider.Provider switch
{
Provider.Premiumize => premiumizeDebridClient,
Provider.RealDebrid => realDebridDebridClient,
@ -61,7 +63,7 @@ public class Torrents(
public virtual (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetDownloadStats(Guid downloadId)
{
return TorrentRunner.GetStats(downloadId);
return runnerState.GetStats(downloadId);
}
public virtual async Task<IList<Torrent>> Get()
@ -197,9 +199,9 @@ public class Torrents(
throw new($"{ex.Message}, trying to parse {magnetLink}");
}
if (!String.IsNullOrWhiteSpace(Settings.Get.General.BannedTrackers))
if (!String.IsNullOrWhiteSpace(settings.Current.General.BannedTrackers))
{
var bannedTrackers = Settings.Get.General.BannedTrackers.Split(',');
var bannedTrackers = settings.Current.General.BannedTrackers.Split(',');
foreach (var bannedTracker in bannedTrackers)
{
@ -264,9 +266,9 @@ public class Torrents(
throw new($"{ex.Message}, trying to parse {fileAsBase64}");
}
if (!String.IsNullOrWhiteSpace(Settings.Get.General.BannedTrackers))
if (!String.IsNullOrWhiteSpace(settings.Current.General.BannedTrackers))
{
var bannedTrackers = Settings.Get.General.BannedTrackers.Split(',');
var bannedTrackers = settings.Current.General.BannedTrackers.Split(',');
foreach (var bannedTracker in bannedTrackers)
{
@ -312,16 +314,16 @@ public class Torrents(
private async Task CopyAddedTorrent(Torrent torrent)
{
if (String.IsNullOrWhiteSpace(Settings.Get.General.CopyAddedTorrents) || String.IsNullOrWhiteSpace(torrent.FileOrMagnet) || String.IsNullOrWhiteSpace(torrent.RdName))
if (String.IsNullOrWhiteSpace(settings.Current.General.CopyAddedTorrents) || String.IsNullOrWhiteSpace(torrent.FileOrMagnet) || String.IsNullOrWhiteSpace(torrent.RdName))
{
return;
}
try
{
if (!fileSystem.Directory.Exists(Settings.Get.General.CopyAddedTorrents))
if (!fileSystem.Directory.Exists(settings.Current.General.CopyAddedTorrents))
{
fileSystem.Directory.CreateDirectory(Settings.Get.General.CopyAddedTorrents);
fileSystem.Directory.CreateDirectory(settings.Current.General.CopyAddedTorrents);
}
var extension = torrent.Type switch
@ -331,7 +333,7 @@ public class Torrents(
_ => throw new ArgumentException("Unexpected DownloadType")
};
var copyFileName = Path.Combine(Settings.Get.General.CopyAddedTorrents, FileHelper.RemoveInvalidFileNameChars(torrent.RdName));
var copyFileName = Path.Combine(settings.Current.General.CopyAddedTorrents, FileHelper.RemoveInvalidFileNameChars(torrent.RdName));
if (!copyFileName.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
{
@ -355,7 +357,7 @@ public class Torrents(
}
catch (Exception ex)
{
logger.LogError(ex, $"Unable to create torrent blackhole directory: {Settings.Get.General.CopyAddedTorrents}: {ex.Message}");
logger.LogError(ex, $"Unable to create torrent blackhole directory: {settings.Current.General.CopyAddedTorrents}: {ex.Message}");
}
}
@ -457,12 +459,23 @@ public class Torrents(
foreach (var downloadInfo in downloadInfos)
{
// Make sure downloads don't get added multiple times
var downloadExists = await downloads.Get(torrent.TorrentId, downloadInfo.RestrictedLink);
var addResult = await downloads.TryAddForTorrent(torrent.TorrentId, downloadInfo);
if (downloadExists == null && !String.IsNullOrWhiteSpace(downloadInfo.RestrictedLink))
switch (addResult)
{
await downloads.Add(torrent.TorrentId, downloadInfo);
case DownloadAddResult.Added:
case DownloadAddResult.AlreadyExists:
continue;
case DownloadAddResult.TorrentMissing:
logger.LogDebug("Stopping download creation because the torrent was deleted concurrently. TorrentId: {torrentId}", torrent.TorrentId);
return;
case DownloadAddResult.InvalidInput:
logger.LogDebug("Skipping download creation because the provider returned an invalid download link. TorrentId: {torrentId}", torrent.TorrentId);
continue;
default:
throw new ArgumentOutOfRangeException(nameof(addResult), addResult, null);
}
}
}
@ -500,7 +513,7 @@ public class Torrents(
{
var retry = 10;
while (TorrentRunner.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient))
while (runnerState.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient))
{
Log($"Cancelling download", download, torrent);
@ -518,7 +531,7 @@ public class Torrents(
retry = 10;
while (TorrentRunner.ActiveUnpackClients.TryGetValue(download.DownloadId, out var unpackClient))
while (runnerState.ActiveUnpackClients.TryGetValue(download.DownloadId, out var unpackClient))
{
Log($"Cancelling unpack", download, torrent);
@ -539,7 +552,6 @@ public class Torrents(
{
Log($"Deleting RdtClient data", torrent);
await downloads.DeleteForTorrent(torrent.TorrentId);
await torrentData.Delete(torrentId);
}
@ -559,7 +571,7 @@ public class Torrents(
if (deleteLocalFiles && !String.IsNullOrWhiteSpace(torrent.RdName))
{
var downloadPath = DownloadPath(torrent);
var downloadPath = DownloadPath(torrent, settings.Current);
downloadPath = Path.Combine(downloadPath, torrent.RdName);
Log($"Deleting local files in {downloadPath}", torrent);
@ -605,7 +617,6 @@ public class Torrents(
return unrestrictedLink;
}
/// <inheritdoc />
public async Task<String> RetrieveFileName(Guid downloadId)
{
var download = await downloads.GetById(downloadId) ?? throw new($"Download with ID {downloadId} not found");
@ -625,13 +636,13 @@ public class Torrents(
var profile = new Profile
{
Provider = Enum.GetName(Settings.Get.Provider.Provider),
Provider = Enum.GetName(settings.Current.Provider.Provider),
UserName = user.Username,
Expiration = user.Expiration,
CurrentVersion = UpdateChecker.CurrentVersion,
LatestVersion = UpdateChecker.LatestVersion,
IsInsecure = UpdateChecker.IsInsecure,
DisableUpdateNotification = Settings.Get.General.DisableUpdateNotifications
DisableUpdateNotification = settings.Current.General.DisableUpdateNotifications
};
return profile;
@ -646,31 +657,65 @@ public class Torrents(
try
{
var rdTorrents = await DebridClient.GetDownloads();
var torrentsByRdId = CreateTorrentLookupByRdId(torrents);
var providerTorrentsById = CreateProviderTorrentLookupById(rdTorrents);
foreach (var rdTorrent in rdTorrents)
{
var torrent = torrents.FirstOrDefault(m => m.RdId == rdTorrent.Id);
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.Get.Provider.AutoImport)
if (torrent == null && settings.Current.Provider.AutoImport)
{
var newTorrent = new Torrent
{
Category = Settings.Get.Provider.Default.Category,
DownloadClient = Settings.Get.DownloadClient.Client,
Category = settings.Current.Provider.Default.Category,
DownloadClient = settings.Current.DownloadClient.Client,
DownloadAction =
Settings.Get.Provider.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
HostDownloadAction = Settings.Get.Provider.Default.HostDownloadAction,
FinishedActionDelay = Settings.Get.Provider.Default.FinishedActionDelay,
FinishedAction = Settings.Get.Provider.Default.FinishedAction,
DownloadMinSize = Settings.Get.Provider.Default.MinFileSize,
IncludeRegex = Settings.Get.Provider.Default.IncludeRegex,
ExcludeRegex = Settings.Get.Provider.Default.ExcludeRegex,
TorrentRetryAttempts = Settings.Get.Provider.Default.TorrentRetryAttempts,
DownloadRetryAttempts = Settings.Get.Provider.Default.DownloadRetryAttempts,
DeleteOnError = Settings.Get.Provider.Default.DeleteOnError,
Lifetime = Settings.Get.Provider.Default.TorrentLifetime,
Priority = Settings.Get.Provider.Default.Priority > 0 ? Settings.Get.Provider.Default.Priority : null,
settings.Current.Provider.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
HostDownloadAction = settings.Current.Provider.Default.HostDownloadAction,
FinishedActionDelay = settings.Current.Provider.Default.FinishedActionDelay,
FinishedAction = settings.Current.Provider.Default.FinishedAction,
DownloadMinSize = settings.Current.Provider.Default.MinFileSize,
IncludeRegex = settings.Current.Provider.Default.IncludeRegex,
ExcludeRegex = settings.Current.Provider.Default.ExcludeRegex,
TorrentRetryAttempts = settings.Current.Provider.Default.TorrentRetryAttempts,
DownloadRetryAttempts = settings.Current.Provider.Default.DownloadRetryAttempts,
DeleteOnError = settings.Current.Provider.Default.DeleteOnError,
Lifetime = settings.Current.Provider.Default.TorrentLifetime,
Priority = settings.Current.Provider.Default.Priority > 0 ? settings.Current.Provider.Default.Priority : null,
RdId = rdTorrent.Id
};
@ -679,7 +724,9 @@ public class Torrents(
continue;
}
torrent = await torrentData.Add(rdTorrent.Id, rdTorrent.Hash, null, false, DownloadType.Torrent, Settings.Get.DownloadClient.Client, newTorrent);
torrent = await torrentData.Add(rdTorrent.Id, rdTorrent.Hash, null, false, DownloadType.Torrent, settings.Current.DownloadClient.Client, newTorrent);
torrentsByRdId[rdTorrent.Id] = torrent;
torrents.Add(torrent);
await UpdateTorrentClientData(torrent, rdTorrent);
}
@ -691,9 +738,9 @@ public class Torrents(
foreach (var torrent in torrents)
{
var rdTorrent = rdTorrents.FirstOrDefault(m => m.Id == torrent.RdId);
var rdTorrent = torrent.RdId != null && providerTorrentsById.TryGetValue(torrent.RdId, out var providerTorrent) ? providerTorrent : null;
if (rdTorrent == null && Settings.Get.Provider.AutoDelete && torrent.RdStatus != TorrentStatus.Queued)
if (rdTorrent == null && settings.Current.Provider.AutoDelete && torrent.RdStatus != TorrentStatus.Queued)
{
await Delete(torrent.TorrentId, true, false, true);
}
@ -731,14 +778,14 @@ public class Torrents(
foreach (var download in torrent.Downloads)
{
while (TorrentRunner.ActiveDownloadClients.TryRemove(download.DownloadId, out var downloadClient))
while (runnerState.ActiveDownloadClients.TryRemove(download.DownloadId, out var downloadClient))
{
await downloadClient.Cancel();
await Task.Delay(100);
}
while (TorrentRunner.ActiveUnpackClients.TryRemove(download.DownloadId, out var unpackClient))
while (runnerState.ActiveUnpackClients.TryRemove(download.DownloadId, out var unpackClient))
{
unpackClient.Cancel();
@ -801,21 +848,21 @@ public class Torrents(
Log($"Retrying Download", download, download.Torrent);
while (TorrentRunner.ActiveDownloadClients.TryRemove(download.DownloadId, out var downloadClient))
while (runnerState.ActiveDownloadClients.TryRemove(download.DownloadId, out var downloadClient))
{
await downloadClient.Cancel();
await Task.Delay(100);
}
while (TorrentRunner.ActiveUnpackClients.TryRemove(download.DownloadId, out var unpackClient))
while (runnerState.ActiveUnpackClients.TryRemove(download.DownloadId, out var unpackClient))
{
unpackClient.Cancel();
await Task.Delay(100);
}
var downloadPath = DownloadPath(download.Torrent!);
var downloadPath = DownloadPath(download.Torrent!, settings.Current);
var filePath = DownloadHelper.GetDownloadPath(downloadPath, download.Torrent!, download);
@ -843,6 +890,43 @@ public class Torrents(
await torrentData.UpdateFilesSelected(torrentId, datetime);
}
public async Task<Boolean> UpdateFileSelection(String hash, IReadOnlyCollection<Int32> fileIds, Boolean selected)
{
if (fileIds.Count == 0)
{
return false;
}
var torrent = await torrentData.GetByHash(hash);
if (torrent == null)
{
return false;
}
var files = torrent.Files.ToList();
if (files.Count == 0)
{
return false;
}
foreach (var fileId in fileIds)
{
if (fileId < 0 || fileId >= files.Count)
{
continue;
}
files[fileId].Selected = selected;
}
torrent.RdFiles = JsonSerializer.Serialize(files, JsonSerializerOptions);
await torrentData.UpdateRdData(torrent);
return true;
}
public async Task UpdatePriority(String hash, Int32 priority)
{
var torrent = await torrentData.GetByHash(hash);
@ -879,9 +963,9 @@ public class Torrents(
return torrent;
}
private static String DownloadPath(Torrent torrent)
private static String DownloadPath(Torrent torrent, DbSettings settings)
{
var settingDownloadPath = Settings.Get.DownloadClient.DownloadPath;
var settingDownloadPath = settings.DownloadClient.DownloadPath;
if (!String.IsNullOrWhiteSpace(torrent.Category))
{
@ -920,11 +1004,11 @@ public class Torrents(
await torrentData.Update(torrent);
}
public async Task RunTorrentComplete(Guid torrentId, DbSettings? settings = null)
public async Task RunTorrentComplete(Guid torrentId, DbSettings? runSettings = null)
{
settings ??= Settings.Get;
runSettings ??= settings.Current;
if (String.IsNullOrWhiteSpace(settings.General.RunOnTorrentCompleteFileName))
if (String.IsNullOrWhiteSpace(runSettings.General.RunOnTorrentCompleteFileName))
{
return;
}
@ -933,12 +1017,12 @@ public class Torrents(
var downloadsForTorrent = await downloads.GetForTorrent(torrentId);
var fileName = settings.General.RunOnTorrentCompleteFileName;
var arguments = settings.General.RunOnTorrentCompleteArguments ?? "";
var fileName = runSettings.General.RunOnTorrentCompleteFileName;
var arguments = runSettings.General.RunOnTorrentCompleteArguments ?? "";
Log($"Parsing external program {fileName} with arguments {arguments}", torrent);
var downloadPath = DownloadPath(torrent);
var downloadPath = DownloadPath(torrent, runSettings);
var torrentPath = Path.Combine(downloadPath, torrent.RdName ?? "Unknown");
var filePath = torrentPath;
@ -1022,23 +1106,83 @@ public class Torrents(
{
try
{
var originalTorrent = JsonSerializer.Serialize(torrent, JsonSerializerOptions);
var originalTorrent = CaptureRdState(torrent);
await DebridClient.UpdateData(torrent, torrentClientTorrent);
var newTorrent = JsonSerializer.Serialize(torrent, JsonSerializerOptions);
var newTorrent = CaptureRdState(torrent);
if (originalTorrent != newTorrent)
{
await torrentData.UpdateRdData(torrent);
}
}
catch
catch (Exception)
{
// ignored
}
}
private static Dictionary<String, Torrent> CreateTorrentLookupByRdId(IEnumerable<Torrent> torrents)
{
var lookup = new Dictionary<String, Torrent>(StringComparer.Ordinal);
foreach (var torrent in torrents)
{
if (!String.IsNullOrWhiteSpace(torrent.RdId))
{
lookup[torrent.RdId] = torrent;
}
}
return lookup;
}
private static Dictionary<String, DebridClientTorrent> CreateProviderTorrentLookupById(IEnumerable<DebridClientTorrent> torrents)
{
var lookup = new Dictionary<String, DebridClientTorrent>(StringComparer.Ordinal);
foreach (var torrent in torrents)
{
if (!String.IsNullOrWhiteSpace(torrent.Id))
{
lookup[torrent.Id] = torrent;
}
}
return lookup;
}
private static TorrentRdState CaptureRdState(Torrent torrent)
{
return new(torrent.RdName,
torrent.RdSize,
torrent.RdHost,
torrent.RdSplit,
torrent.RdProgress,
torrent.RdStatus,
torrent.RdStatusRaw,
torrent.RdAdded,
torrent.RdEnded,
torrent.RdSpeed,
torrent.RdSeeders,
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 void Log(String message, Download? download, Torrent? torrent)
{
if (download != null)

View file

@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Models.QBittorrent;
using RdtClient.Service.Services;
using RdtClient.Web.Controllers;
namespace RdtClient.Web.Test.Controllers;
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,
_torrentsMock.Object);
_controller.ControllerContext = new()
{
HttpContext = new DefaultHttpContext()
};
}
[Fact]
public async Task TorrentsInfo_FilterAll_DoesNotFilterOutResults()
{
// Arrange
_qBittorrentMock.Setup(q => q.TorrentInfo())
.ReturnsAsync(new List<TorrentInfo>
{
new()
{
Hash = "hash1",
State = "pausedUP",
Progress = 1f
}
});
// Act
var result = await _controller.TorrentsInfo(new()
{
Filter = "all",
Hashes = "hash1"
});
// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsAssignableFrom<IList<TorrentInfo>>(okResult.Value);
Assert.Single(payload);
Assert.Equal("hash1", payload[0].Hash);
}
[Fact]
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
}
});
// Act
var result = await _controller.TorrentsInfo(new()
{
Filter = "completed"
});
// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsAssignableFrom<IList<TorrentInfo>>(okResult.Value);
Assert.Single(payload);
Assert.Equal("hash1", payload[0].Hash);
}
}

View file

@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Data;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Sabnzbd;
using RdtClient.Service.Services;
@ -17,15 +16,17 @@ public class SabnzbdControllerTest
private readonly Mock<Authentication> _authenticationMock;
private readonly SabnzbdController _controller;
private readonly Mock<Sabnzbd> _sabnzbdMock;
private readonly TestSettings _settings;
public SabnzbdControllerTest()
{
SettingData.Get.General.AuthenticationType = AuthenticationType.None;
SettingData.Get.Provider.ApiKey = "test-api-key";
_settings = new();
_settings.Current.General.AuthenticationType = AuthenticationType.None;
_settings.Current.Provider.ApiKey = "test-api-key";
var torrentsMock = new Mock<Torrents>(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
var torrentsMock = new Mock<Torrents>(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
var sabnzbdLoggerMock = new Mock<ILogger<Sabnzbd>>();
_sabnzbdMock = new(sabnzbdLoggerMock.Object, torrentsMock.Object, null!);
_sabnzbdMock = new(sabnzbdLoggerMock.Object, torrentsMock.Object, null!, _settings);
var loggerMock = new Mock<ILogger<SabnzbdController>>();
_authenticationMock = new(null!, null!, null!);
@ -64,7 +65,7 @@ public class SabnzbdControllerTest
public async Task GetQueue_Unauthorized_ReturnsOk_BecauseFilterIsSkippedInUnitTests()
{
// Arrange
SettingData.Get.General.AuthenticationType = AuthenticationType.UserNamePassword;
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var queue = new SabnzbdQueue
{
@ -86,7 +87,7 @@ public class SabnzbdControllerTest
public async Task GetQueue_WithMaAuth_ReturnsOk()
{
// Arrange
SettingData.Get.General.AuthenticationType = AuthenticationType.UserNamePassword;
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext();
httpContext.Request.QueryString = new("?ma_username=user&ma_password=pass");
_controller.ControllerContext.HttpContext = httpContext;
@ -107,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()
{
@ -128,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()
{
@ -146,7 +191,7 @@ public class SabnzbdControllerTest
public void GetVersion_ReturnsOk()
{
// Arrange
SettingData.Get.General.AuthenticationType = AuthenticationType.UserNamePassword;
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
// Act
var result = _controller.Version();

View file

@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Moq;
using RdtClient.Data.Data;
using RdtClient.Data.Enums;
using RdtClient.Service.Middleware;
using RdtClient.Service.Services;
@ -15,20 +14,22 @@ public class SabnzbdHandlerTest
private readonly Mock<Authentication> _authenticationMock;
private readonly SabnzbdHandler _handler;
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
private readonly TestSettings _settings;
public SabnzbdHandlerTest()
{
_authenticationMock = new(null!, null!, null!);
_httpContextAccessorMock = new();
_handler = new(_authenticationMock.Object, _httpContextAccessorMock.Object);
SettingData.Get.General.AuthenticationType = AuthenticationType.UserNamePassword;
_settings = new();
_handler = new(_authenticationMock.Object, _httpContextAccessorMock.Object, _settings);
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
}
[Fact]
public async Task HandleAsync_AuthNone_Succeeds()
{
// Arrange
SettingData.Get.General.AuthenticationType = AuthenticationType.None;
_settings.Current.General.AuthenticationType = AuthenticationType.None;
var context = CreateContext();
// Act
@ -42,9 +43,16 @@ public class SabnzbdHandlerTest
public async Task HandleAsync_ValidCredentials_Succeeds()
{
// Arrange
Settings.Get.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext();
httpContext.Request.QueryString = new("?ma_username=user&ma_password=pass");
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
{
QueryString = new("?ma_username=user&ma_password=pass")
}
};
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);
var context = CreateContext(httpContext);
@ -57,11 +65,119 @@ 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()
{
// Arrange
Settings.Get.General.AuthenticationType = AuthenticationType.UserNamePassword;
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext();
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity("TestAuth"));
httpContext.User = claimsPrincipal;
@ -80,9 +196,16 @@ public class SabnzbdHandlerTest
public async Task HandleAsync_InvalidCredentials_DoesNotSucceed()
{
// Arrange
Settings.Get.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext();
httpContext.Request.QueryString = new("?ma_username=user&ma_password=wrong");
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext
{
Request =
{
QueryString = new("?ma_username=user&ma_password=wrong")
}
};
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);
var context = CreateContext(httpContext);
@ -95,11 +218,66 @@ 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()
{
// Arrange
Settings.Get.General.AuthenticationType = AuthenticationType.UserNamePassword;
_settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword;
var httpContext = new DefaultHttpContext();
_httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);

View file

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Service.Helpers;
using RdtClient.Service.Services;
using RdtClient.Web.Controllers;
@ -10,14 +11,16 @@ namespace RdtClient.Web.Test.Controllers;
public class TorrentsControllerNzbTest
{
private readonly TorrentsController _controller;
private readonly Mock<IRateLimitCoordinator> _coordinatorMock;
private readonly Mock<ILogger<TorrentsController>> _loggerMock;
private readonly Mock<Torrents> _torrentsMock;
public TorrentsControllerNzbTest()
{
_torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
_torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, new TestSettings(), new TorrentRunnerState());
_loggerMock = new();
_controller = new(_loggerMock.Object, _torrentsMock.Object, null!);
_coordinatorMock = new();
_controller = new(_loggerMock.Object, _torrentsMock.Object, null!, _coordinatorMock.Object);
}
[Fact]

View file

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

View file

@ -5,14 +5,15 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<AssemblyName>RdtClient.Web.Test</AssemblyName>
<RootNamespace>RdtClient.Web.Test</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.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="SharpCompress" Version="[0.42.1]" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>

Some files were not shown because too many files have changed in this diff Show more