Compare commits

...

537 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
Roger Far
178270084e 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-02-23 20:14:27 -07:00
Roger Far
2743089b3f v1.0.124 2026-02-23 20:10:29 -07:00
Roger Far
41f816ab80 Cleanup. 2026-02-23 20:04:53 -07:00
Roger Far
81dd3dd9f8
Merge pull request #923 from omgbeez/upstream/resilience
Add resilience handler, predictive rate limit handling
2026-02-23 08:36:35 -07:00
Roger Far
0af0b41bb8
Merge pull request #935 from ALenfant/add_legacy_version_endpoint
Add legacy /version/api endpoint
2026-02-22 10:40:18 -07:00
Antonin Lenfant-Kodia
e01cd6a979 Add legacy /version/api endpoint 2026-02-22 14:49:07 +01:00
Roger Far
480d881d92 Reverted SharpCompress to 0.42.1 due to file locking issues.
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-02-21 16:04:36 -07:00
Roger Far
11ccf5a2d4 v2.0.122
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-02-21 15:36:32 -07:00
Roger Far
b7df2bfb15 Added test helper to make sure tests pass on Windows. 2026-02-21 15:33:12 -07:00
Roger Far
7c94528847
Merge pull request #931 from omgbeez/upstream/nzb-delete-actions
Handle NZB finished actions in `Sabnzbd` service
2026-02-21 15:18:41 -07:00
Roger Far
f4e1cf4717
Merge pull request #930 from omgbeez/upstream/downloads-fix
Fix broken download status in the UI
2026-02-21 15:18:08 -07:00
Roger Far
d023bddd24
Merge pull request #928 from omgbeez/upstream/tb-statuses
Adding some missing status mappings for TorBox
2026-02-21 15:17:13 -07:00
Roger Far
8e5238844e
Merge pull request #926 from omgbeez/upstream/qbt-eta-fix
Fix ETA calculation for QBittorrent API
2026-02-21 15:16:16 -07:00
Roger Far
9e69020df8
Merge pull request #925 from omgbeez/upstream/sorting
Update default torrent sorting to `added` in descending order
2026-02-21 15:15:21 -07:00
Roger Far
bcb24e3bfd
Merge pull request #924 from omgbeez/upstream/eta-fix
Update `Logger` to fetch download stats from `TorrentRunner` instead …
2026-02-21 15:14:34 -07:00
omgbeez
e36a5a07e5 Handle NZB finished actions in Sabnzbd service 2026-02-20 17:23:21 -05:00
omgbeez
db511929cd Addressing bug from qodo review 2026-02-20 16:13:25 -05:00
omgbeez
6dedb621d1 Fix sorting logic to default to descending order for new properties 2026-02-20 16:09:56 -05:00
omgbeez
2db2af4cea Fix broken download status in the UI 2026-02-20 15:59:47 -05:00
omgbeez
3eda321d64 Make sort settings persistent. 2026-02-20 14:42:38 -05:00
omgbeez
f32e8316e2 Adding some missing status mappings for TorBox 2026-02-20 11:38:57 -05:00
omgbeez
0ca2517eb6 Fix ETA calculation for QBittorrent API 2026-02-20 10:56:56 -05:00
omgbeez
6d0cb6ceb5 Minor fixes for Qodo 2026-02-20 10:34:30 -05:00
omgbeez
f2dab9f6e1 Update default sort field to 'added' and direction to 'desc' in torrent table component 2026-02-20 10:01:19 -05:00
omgbeez
ed397f8223 Update Logger to use TorrentRunner.GetStats for consistent download statistics 2026-02-20 09:57:57 -05:00
omgbeez
a023d33d90 Add resilience handler, predictive rate limit handling
- Applied to TorBox, can be extended easily to other providers
- Reads response headers commonplace for pre-emptive rate limit throttling and retry-after
- When a rate limit is reached, displays a warning in the UI
- Fix socket leak from direct allocation of HttpClient, replaced with factory which handles pooling and re-use of sockets.

While HttpClient is Disposable, it doesn't gaurantee (and does not) directly release underlying sockets for queries at the time the client is disposed. These sockets will go into a TCP WAIT state often for a very long time. The expected pattern in C# is to always use the HttClientFactory which will correctly handle re-use of the OS sockets in suqsequent queries reducing resource and memory leaks.
2026-02-20 09:47:13 -05:00
Roger Far
6572ba752c Bump to 2.0.121
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-02-18 21:43:00 -07:00
Roger Far
ccee652572 Upgrade packages. 2026-02-18 21:35:02 -07:00
Roger Far
d6020594ef Cleanup. 2026-02-18 21:34:51 -07:00
Roger Far
d0242f235c
Merge pull request #911 from omgbeez/usenet
Adding usenet support (Torbox only implementation)
2026-02-18 21:29:21 -07:00
omgbeez
991ecefe3c Clarify error messages in TorrentRunner to distinguish between provider and runner errors. 2026-02-13 17:09:16 -05:00
omgbeez
33d8c2d825 Align Sabnzbd status reporting with updated TorrentStatus values. 2026-02-13 17:08:51 -05:00
omgbeez
f25cef711b Improve TorBox status mapping to TorrentStatus and log any unmapped statuses for debugging. 2026-02-13 17:06:57 -05:00
omgbeez
40be170677 Refactor download progress calculation to use centralized stats and simplify logic across services.
- Fixes an issue where progress goes back to 50% as soon as downloads are done
- Prevents copy and update of torrent/nzb list by not updating download stats on every use, most callers don't require this data, notable perf/memory improvement.
2026-02-13 17:06:12 -05:00
omgbeez
a5f9cd353b SqLite doesn't support OrderBy on DateTimeOffset types
Sorting will require a copy to fix this for the moment, could probably change the underlying type later to prevent this.
2026-02-13 13:22:41 -05:00
omgbeez
e93e4f6f22 Usenet support 2026-02-13 13:15:57 -05:00
Roger Far
d76d26131c Remove obsolete caching. 2026-02-11 20:24:27 -07:00
Roger Far
580c7132c6 Cleanup. 2026-02-11 20:22:28 -07:00
Roger Far
e51e5cde45 Merge branch 'main' of https://github.com/rogerfar/rdt-client 2026-02-11 20:21:08 -07:00
Roger Far
a68eec1bfa
Merge pull request #903 from Chesyre/main
perf: Major performance optimizations - memory leak fix, SQLite tuning, and cache improvements
2026-02-11 20:20:54 -07:00
Roger Far
0c5e38e01a
Merge branch 'main' into main 2026-02-11 20:20:47 -07:00
Roger Far
7aca0eaedd Merge branch 'main' of https://github.com/rogerfar/rdt-client 2026-02-11 20:08:21 -07:00
Roger Far
da576ef2c4
Merge pull request #913 from omgbeez/upstream/websocket-perf
Optimize websocket data sending to the UI
2026-02-11 20:08:13 -07:00
Roger Far
01a26c614b
Merge branch 'main' into upstream/websocket-perf 2026-02-11 20:04:57 -07:00
Roger Far
871802ad34 Merge branch 'main' of https://github.com/rogerfar/rdt-client 2026-02-11 20:02:41 -07:00
Roger Far
0f8921e1fb
Merge pull request #916 from omgbeez/upstream/qbt-eta
Add ETA calculation for torrents.
2026-02-11 20:02:32 -07:00
Roger Far
6b85d7bbbc
Merge branch 'main' into upstream/qbt-eta 2026-02-11 20:02:18 -07:00
Roger Far
25c06ab6fc Merge branch 'main' of https://github.com/rogerfar/rdt-client 2026-02-11 19:59:42 -07:00
Roger Far
ca325aaac1
Merge pull request #919 from jwmann/fix/qbittorrent-health-check-api
Fix qBittorrent Health Check API for Cleanuparr & others
2026-02-11 19:59:35 -07:00
Roger Far
99b2ad8e27 Update readme to reflect .NET 10. 2026-02-11 19:59:07 -07:00
Roger Far
b539e8933e 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-02-11 19:50:14 -07:00
Roger Far
a9648248f4 Global formatting. 2026-02-11 19:44:49 -07:00
Roger Far
24321a55c9 Upgrade Angular to 21 and .NET to 10. 2026-02-11 19:40:23 -07:00
Roger Far
028c9f06ec Upgrade to Angular 21. 2026-02-11 19:17:44 -07:00
jwmann
b74b5dd7a9
Add AllowAnonymous to QBittorrent health check endpoints
This fix allows Cleanuparr to successfully connect to RDT Client by making the
health check endpoints accessible without authentication, matching real qBittorrent
behavior. The three endpoints affected are:
- /api/v2/app/version
- /api/v2/app/webapiVersion
- /api/v2/app/buildInfo

Fixes #867
2026-02-11 19:08:59 -05:00
omgbeez
42ebd2c74b Calculate remaining time based on download speed and bytes remaining 2026-01-17 12:33:31 -05:00
omgbeez
9454ea1bd1 Refactored torrent and download models with DTOs to simplify serialization and API responses. 2026-01-17 12:23:16 -05:00
omgbeez
6cef70b979 Simplify download progress and remaining time calculations in QBittorrent service. 2026-01-17 12:21:13 -05:00
destrodxbad
15af54ed24 perf(cache): Remove aggressive cache invalidation and optimize queries 2025-11-18 02:04:51 +01:00
destrodxbad
47355030b5 perf(database): Configure SQLite with WAL mode and connection pooling 2025-11-18 02:03:13 +01:00
destrodxbad
a7bb8da1d5 perf(websocket): Fix scope leak and add adaptive polling 2025-11-18 02:01:41 +01:00
Roger Far
371a13e060
Merge pull request #901 from Chesyre/main
Add automatic disk space monitoring for Bezzad downloader
2025-11-15 17:15:01 -07:00
destrodxbad
e606501834 Fix: Refresh disk space notification during pause and after reconnection 2025-11-14 20:29:38 +01:00
destrodxbad
6db556ed5c Fix: Bug refresh 2025-11-14 18:04:41 +01:00
destrodxbad
f12f28a8bb Fix: Refresh disk space status while paused 2025-11-14 15:17:11 +01:00
destrodxbad
57ea1b3881 Add proper TypeScript typing for disk space status 2025-11-14 14:44:08 +01:00
Roger Far
ed218d76ce
Merge pull request #900 from saif191020/main
Added Css to Improve Mobile support on the home page
2025-11-13 19:07:52 -07:00
destrodxbad
27398ba993 Add automatic disk space monitoring for Bezzad downloader 2025-11-09 03:05:56 +01:00
Saif Basheer Ahamed
994f167ab3 Updated Css for better support on mobiles in the torrent table in homepage 2025-11-08 14:33:09 +05:30
Roger Versluis
c51f8cb503 Removed internal downloader from the GUI.
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
2025-10-13 16:46:27 -06:00
Roger Versluis
1080143fce Added some fake qBittorrent API calls for decluttarr.
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
2025-10-08 21:26:14 -06:00
Roger Versluis
90c1092fe5 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
2025-10-06 20:16:39 -06:00
Roger Versluis
123c4f29c6 Remove internal downloader. 2025-10-06 20:15:07 -06:00
Roger Far
dadab50840 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
2025-08-04 22:41:25 -06:00
Roger Far
eb80e9083b Merge branch 'main' of https://github.com/rogerfar/rdt-client 2025-08-04 22:40:42 -06:00
Roger Far
02a97f3170 Add banned tracker setting. 2025-08-04 22:40:11 -06:00
Roger Far
2e8a7ce080
Merge pull request #836 from Cucumberrbob/chore/angular-v20
update to angular 20 (plus other frontend gardening)
2025-07-29 13:05:59 -06:00
Cucumberrbob
48f00e0459
Merge branch 'main' into chore/angular-v20 2025-07-29 19:29:05 +01:00
Roger Far
21dd80a061 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
2025-07-28 22:35:23 -06:00
Roger Far
044e04c694 no message 2025-07-28 22:31:40 -06:00
Roger Far
f3d0b31fea Merge branch 'main' of https://github.com/rogerfar/rdt-client 2025-07-28 22:00:45 -06:00
Roger Far
25032a4e65
Merge pull request #857 from Cucumberrbob/feat/completed-torrent-grace-period
add setting to delay `FinishedAction`
2025-07-28 22:00:31 -06:00
Roger Far
d9858409db Cleanup, make sure the RD API adheres to the set timeout when trying to add a magnet or file. 2025-07-28 21:46:20 -06:00
Cucumberrbob
30b9b9a4ad
run prettier 2025-07-06 22:08:28 +01:00
Cucumberrbob
1be3e98ad7
Update server/RdtClient.Service/Services/TorrentRunner.cs
Co-authored-by: qodo-merge-pro[bot] <151058649+qodo-merge-pro[bot]@users.noreply.github.com>
2025-07-06 22:05:29 +01:00
Cucumberrbob
6bc9c06faa
add setting to delay FinishedAction 2025-07-06 21:59:36 +01:00
Cucumberrbob
1fe407ba5a
correct dockerhub image tag in docker readme
Closes #854
2025-07-03 21:05:21 +01:00
Cucumberrbob
6d57aafcfe
Add changelog for v2.0.114
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
2025-06-21 12:15:29 +01:00
Roger Far
3bc657dbb2
Merge pull request #842 from Cucumberrbob/feat/always-set-user-agent
set user agent for all `HttpClient`s from `IHttpClientFactory`
2025-06-19 11:28:53 -06:00
Cucumberrbob
0461058d86
set user agent for all HttpClients from IHttpClientFactory 2025-06-14 14:02:39 +01:00
Cucumberrbob
a24928f9eb
add standalone: true to all components 2025-06-07 18:56:40 +01:00
Cucumberrbob
a7cab6d0dc
convert unmutated lets to consts 2025-06-07 18:47:12 +01:00
Cucumberrbob
6909fb3851
assign progress in one go
rather than checking for `NaN`, just `|| 0` - it will leave `0` as is, and convert any `NaN`s to `0`
2025-06-07 18:43:42 +01:00
Cucumberrbob
f5f2760f80
merge declaration and initialisation of speed in TorrentStatusPipe 2025-06-07 18:41:06 +01:00
Cucumberrbob
08c1750def
remove unused css
`scroll-container`, `is-fullwidth-label`, `is-block`, and `strike-through` were all removed in 116ca29,
`fa-download` was removed in 3ee687f61
2025-06-07 18:35:59 +01:00
Cucumberrbob
aa512d1a33
use const not var 2025-06-07 18:35:57 +01:00
Cucumberrbob
3626f8cb97
make for attributes correspond to input ids on labels in signup/login forms 2025-06-07 18:35:54 +01:00
Cucumberrbob
6277a4ca5e
use non-deprecated throwError call signature 2025-06-07 18:35:49 +01:00
Cucumberrbob
e48f1d91a4
make auth resolver standalone 2025-06-07 18:35:45 +01:00
Cucumberrbob
2e7c97d023
use non-deprecated observer .subscribe call signature
https://rxjs.dev/deprecations/subscribe-arguments
2025-06-07 18:35:37 +01:00
Cucumberrbob
2b95b8d714
remove unused methods (references removed in 116ca29) 2025-06-07 18:28:45 +01:00
Cucumberrbob
c1e48d35a5
run ng generate @angular:core/standalone --mode "standalone-bootstrap" 2025-06-07 18:27:30 +01:00
Cucumberrbob
4fb9e972f5
run ng generate @angular:core/standalone --mode "convert-to-standalone" 2025-06-07 18:26:58 +01:00
Cucumberrbob
070d80a6fa
update node version in dockerfile so angular will build
angular 20 requires at least node 22.12 (or 20.19)
2025-06-07 18:25:12 +01:00
Cucumberrbob
ed354e93a9
drop curray
use vanilla js array methods instead
2025-06-07 18:25:12 +01:00
Cucumberrbob
9d0528902a
run ng update @angular/core@20 @angular/cli@20 2025-06-07 18:20:22 +01:00
Cucumberrbob
cb29187f72
Merge pull request #825 from mentalblank/select-all-delete-button-on-individual-torrent-screen
Fix checkboxes in #819
2025-05-31 12:42:54 +01:00
Cucumberrbob
32fe929f48
Merge pull request #816 from mentalblank/magnet-enrichment
Tracker Enrichment
2025-05-31 12:42:13 +01:00
MentalBlank
60263ca685 Tracker enrichment test changes 2025-05-29 23:54:47 +10:00
MentalBlank
63322f3b94 Fix checkboxes being on wrong side of modal 2025-05-29 23:27:38 +10:00
MentalBlank
98042f8040 Improved enrichment validation, logging, and test coverage.
Strengthened URL Validation.
Improved HTTP Client Configuration.
Logged total rejected tracker count at the information level.
Logged individual rejected tracker details at the debug level.
Debug logs for bytes and enriched bytes are now conditional.
Converted some tests from Fact to Theory.
Used Assert.Collection in EnricherTest.cs.
Standardized URL Encoding in Enricher.cs.
2025-05-25 21:50:26 +10:00
Cucumberrbob
479239c94d
Merge pull request #819 from mentalblank/select-all-delete-button-on-individual-torrent-screen
Include select all option when deleting a torrent from individual torrent details screen
2025-05-24 12:37:06 +01:00
MentalBlank
4be3280c3a Enricher code refactoring with small tweaks.
Added additional tests.
Changed enricher class to sealed.
Changed logging.
Changed how trackers are added.
Fixed some issues around edge cases.
2025-05-24 02:16:27 +10:00
Roger Far
1973b73b3c 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
2025-05-22 19:39:20 -06:00
Roger Far
bb9cc67ff7 Revert Synolog.Api.Client because of breaking changes. 2025-05-22 19:38:30 -06:00
MentalBlank
452d8cc1cf Split tracker grabbing and parsing logic.
Split grab and parse logic, recheck cache after semaphore lock, timeouts, add URL validation check, add torrent file tests, replace test hash with valid SHA1 format, tracker URL validation, fix token disposal issue
2025-05-23 00:02:07 +10:00
MentalBlank
408c475af5 Tracker list caching with configurable expiration
Add new option to general settings that allows the user to set the time in minutes to cache the tracker list. 0 disables caching. Default value is 60.
2025-05-21 00:45:14 +10:00
MentalBlank
2e04129b00 Include select all option when deleting a torrent from details screen 2025-05-20 03:37:01 +10:00
MentalBlank
80a8a1020c Expand enrichment to torrent files 2025-05-20 03:27:41 +10:00
Roger Far
a06e0202d9 no message
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
Create GitHub Release / Create GitHub release (push) Has been cancelled
Release Docker Image / push-images (push) Has been cancelled
2025-05-18 11:41:16 -06:00
Roger Far
d989e4a497 no message 2025-05-18 11:34:40 -06:00
Roger Far
4801b94c64 Upgrade packages. 2025-05-18 11:31:38 -06:00
Roger Far
772816ced0
Merge pull request #818 from Cucumberrbob/feat/angular-control-flow
migrate to new Angular Control Flow syntax
2025-05-18 11:13:50 -06:00
Roger Far
2a38d43a1b
Merge pull request #817 from Cucumberrbob/feat/turn-off-auto-unpack
Don't unpack if `UnpackLimit` is set to `0`
2025-05-18 11:11:49 -06:00
Roger Far
6ec3b1072b
Update server/RdtClient.Service/Services/TorrentRunner.cs
Co-authored-by: qodo-merge-pro[bot] <151058649+qodo-merge-pro[bot]@users.noreply.github.com>
2025-05-18 11:11:17 -06:00
Roger Far
f131726cce
Merge pull request #813 from Cucumberrbob/fix/providerupdater-logging-categoryname
fix: make `ProviderUpdater` log under the correct category name
2025-05-18 11:06:13 -06:00
Roger Far
b7efb78814
Merge pull request #812 from Cucumberrbob/fix/realdebridupdatelock-when-dequeueing
fix: lock RealDebridUpdateLock when dequeuing, not when adding to the queue
2025-05-18 11:05:53 -06:00
Cucumberrbob
a5d0f7b564
rework some if statements to be a single if/else, fix typo 2025-05-18 15:56:12 +01:00
Cucumberrbob
a0cbe54152
migrate to new Angular Control Flow syntax
also fixes a typo
2025-05-18 15:50:36 +01:00
Cucumberrbob
75202ed5a9
Merge pull request #815 from mentalblank/register-as-default-magnet-handler
Magnet handler registration contextual links
2025-05-18 14:48:49 +01:00
MentalBlank
f93109cc27 Magnet link registration contextual links
Replaced the static magnet handler help text shown when registration is unavailable (due to an insecure context or lack of browser support) with contextual HTML links.
2025-05-18 21:50:34 +10:00
Cucumberrbob
e17c8c5c1a
make setting UnpackLimit to 0 turn unpacking off 2025-05-17 13:30:22 +01:00
Cucumberrbob
5de0bac9d5
reformat EnrichMagnetLink 2025-05-15 23:37:17 +01:00
MentalBlank
ae66b40a3d Allow user to define tracker list 2025-05-16 07:57:48 +10:00
MentalBlank
e6a22f6780 Magnet Enrichment
Add magnet link enrichment using configurable tracker lists from ngosang/trackerslist
2025-05-15 19:48:41 +10:00
Cucumberrbob
ba821ebc2f
use ILogger<ProviderUpdater> for ProviderUpdater, not ILogger<TaskRunner> 2025-05-14 14:45:06 +01:00
Cucumberrbob
e7c2b42bd2
lock RealDebridUpdateLock when dequeuing, not when adding to the queue 2025-05-14 14:05:33 +01:00
Cucumberrbob
ea3839514d
update changelog for 2.0.111
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
2025-05-03 17:20:28 +01:00
Cucumberrbob
9319f099fa
Merge pull request #806 from mentalblank/register-as-default-magnet-handler
Register client as browser's default magnet handler
2025-05-03 17:16:43 +01:00
MentalBlank
fed6d022f8 Improve magnet handler registration
- Removed HTTPS protocol check in `registerMagnetHandler()`. Now using `this.canRegisterMagnetHandler = !!(window.isSecureContext && 'registerProtocolHandler' in navigator);` to determine support for protocol handler registration in a secure context.
- Updated the registration button and help text to respond dynamically based on `canRegisterMagnetHandler` and provide better user experience and messaging when unsupported.
- Wrapped `navigator.registerProtocolHandler()` in a try catch block to handle registration failures gracefully and avoid unhandled exceptions.
2025-05-04 01:41:37 +10:00
MentalBlank
94e9a8ccd0 Move magnet handler registration
Moved magnet handler registration to general settings.
2025-05-02 18:37:08 +10:00
MentalBlank
e28749e465 Register client as browser's default magnet handler 2025-05-01 00:10:14 +10:00
Roger Far
757d112d01
Fixed build number in app.
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
2025-04-24 13:04:28 -06:00
Roger Far
6faa064d94
Merge pull request #802 from Cucumberrbob/fix/version-docker-images
fix: patch version before building docker container in github action
2025-04-24 13:03:56 -06:00
Cucumberrbob
a306500d76
fix: patch version before building docker container in github action 2025-04-24 19:51:02 +01:00
Roger Far
1d707940a8 2.0.109
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
2025-04-23 21:52:16 -06:00
Roger Far
e6686aa35d
Merge pull request #796 from Cucumberrbob/build/multi-arch-docker
use arm GitHub Actions runner to build arm docker image
2025-04-23 21:47:49 -06:00
Roger Far
d8da1a47a7
Merge pull request #788 from Cucumberrbob/fix/build-github-release-on-windows
build: build GitHub release .zip on windows not linux
2025-04-23 21:47:41 -06:00
Roger Far
0265a48aa8
Merge pull request #798 from Cucumberrbob/fix/auto-import-host-download-action
fix: Set `HostDownloadAction` when auto-importing torrents
2025-04-23 21:45:58 -06:00
Roger Far
4f770d923a
Merge pull request #795 from Cucumberrbob/fix/censor-downloadstation-password
fix: censor download station password when logging settings at startup
2025-04-23 21:45:32 -06:00
Roger Far
fa499395bd
Merge pull request #794 from Cucumberrbob/fix/debrid-queue
Debrid Queue fixes (don't auto delete queued torrents, handle errors when dequeueing)
2025-04-23 21:45:10 -06:00
Cucumberrbob
8929ba19d2
fix: Set HostDownloadAction when auto-importing torrents
we had a setting for it, just never used it!
2025-04-22 20:16:18 +01:00
Cucumberrbob
40df762c90
use job outputs not uploaded artifacts in docker image github action 2025-04-20 18:04:13 +01:00
Cucumberrbob
7154cc55cf
use arm runner to build arm docker iamge, combine using docker imagetools 2025-04-20 13:51:50 +01:00
Cucumberrbob
03459d0129
fix: censor download station password when logging settings at startup 2025-04-20 12:14:56 +01:00
Cucumberrbob
5ba630ff52
fix: don't autodelete queued torrents 2025-04-20 12:00:47 +01:00
Cucumberrbob
a4f20fd3a4
fix: catch and handle errors when dequeuing torrent 2025-04-20 12:00:21 +01:00
Cucumberrbob
e10de75494
build: build GitHub release .zip on windows not linux 2025-04-14 22:13:08 +01:00
Roger Far
995fb1c6cf no message
Some checks failed
Release Docker Image / build (push) Has been cancelled
Create GitHub Release / Create GitHub release (push) Has been cancelled
dotnet test / build (push) Has been cancelled
2025-04-13 16:16:36 -06:00
Roger Far
447b48dcf1
Merge pull request #785 from Cucumberrbob/fix/websocket-not-updating
fix: re-enable websocket by running background service
2025-04-13 15:13:56 -06:00
Cucumberrbob
b620e29209
Run WebsocketsUpdater as a HostedService 2025-04-13 18:32:59 +01:00
Roger Far
b1d40e6891 Fixed docker file.
Some checks failed
Release Docker Image / build (push) Has been cancelled
Create GitHub Release / Create GitHub release (push) Has been cancelled
dotnet test / build (push) Has been cancelled
2025-04-13 10:56:42 -06:00
Roger Far
fcbc0b0b35 Fixed changelog.
Some checks failed
Release Docker Image / build (push) Has been cancelled
Create GitHub Release / Create GitHub release (push) Has been cancelled
dotnet test / build (push) Has been cancelled
2025-04-13 10:51:48 -06:00
Roger Far
49ad0f185f Fixed changelog. 2025-04-13 10:48:39 -06:00
Roger Far
11c341b7f8 no message 2025-04-13 10:41:45 -06:00
Roger Far
fab14f1f3f Changed how changelogs are fetched. 2025-04-13 10:39:14 -06:00
Roger Far
a2f031e757 Removed unsupported updating of the GHCR. 2025-04-13 10:24:35 -06:00
Roger Far
c40ea173fa no message
Some checks failed
Docker Image CI / build (push) Has been cancelled
Release / Create GitHub release (push) Has been cancelled
dotnet test / build (push) Has been cancelled
2025-04-13 10:11:01 -06:00
Roger Far
2009ed6ec9 no message 2025-04-13 10:07:23 -06:00
Roger Far
827e94ca89 no message 2025-04-13 10:04:11 -06:00
Roger Far
a1a4e1031d Fixed up github actions. 2025-04-13 09:59:55 -06:00
Roger Far
87efa9dc00 Bump to 2.0.105. 2025-04-13 09:54:29 -06:00
Roger Far
a25cd62ab0 Moved the websocket update process to its own background thread to improve UI update consistency. 2025-04-13 09:53:15 -06:00
Roger Far
781937dc8f Merge branch 'main' of https://github.com/rogerfar/rdt-client 2025-04-13 09:34:34 -06:00
Roger Far
b4385bcaad no message 2025-04-13 09:33:36 -06:00
Cucumberrbob
ea4a25ed93
make botnet-test action run on PRs to main (not master) 2025-04-13 16:23:40 +01:00
Roger Far
b0061abdeb
Merge pull request #763 from Cucumberrbob/feat/debrid-queue
feat: queue torrents before adding to debrid provider
2025-04-13 09:17:13 -06:00
Roger Far
5270375f5c
Merge branch 'main' into feat/debrid-queue 2025-04-13 09:16:46 -06:00
Roger Far
1db70100e2
Merge pull request #739 from Cucumberrbob/feat/github-action
Add `dotnet test` GitHub Action
2025-04-13 09:16:01 -06:00
Cucumberrbob
15d7e7b0cc
Merge branch 'main' into feat/github-action 2025-04-13 15:07:11 +01:00
Roger Far
d436ea2482 Merge branch 'main' of https://github.com/rogerfar/rdt-client 2025-04-13 07:55:26 -06:00
Roger Far
c651d21e81 Cleanup, upgrade packages. 2025-04-13 07:55:22 -06:00
Roger Far
a9485827ce
Merge pull request #783 from Cucumberrbob/feat/tb-optional-zipping
[TB] Make zipped downloads optional
2025-04-13 07:55:05 -06:00
Roger Far
1dff00c3f1
Merge pull request #777 from Cucumberrbob/fix/pm-all-files-excluded
fix: [PM] `GetDownloadLinks`: return empty list not null when all files excluded
2025-04-13 07:53:23 -06:00
Cucumberrbob
478cc0e714
Merge branch 'main' into fix/pm-all-files-excluded 2025-04-13 14:52:09 +01:00
Cucumberrbob
75f115fd42
[TB] Make zipped downloads optional 2025-04-13 14:47:12 +01:00
Roger Far
1ce3d20541
Merge pull request #769 from Cucumberrbob/fix/rd-exclude-all
fix: return number of files selected from `SelectFiles` so we can handle all files excluded for RD
2025-04-13 07:41:55 -06:00
Roger Far
770e32c2cb
Merge pull request #765 from Cucumberrbob/feat/rd-configurable-api-url
feat: add setting to change RD api hostname
2025-04-13 07:41:05 -06:00
Roger Far
88358c8272
Merge pull request #754 from Cucumberrbob/perf/tb-dont-refetch-torrentlist-in-providerupdater
[TB] use `torrentClientTorrent` (if passed) instead of re-fetching in `UpdateData`
2025-04-13 07:40:16 -06:00
Roger Far
ab3ff4a064
Merge pull request #83 from breagan1983/patch-1
Rootless Podman and CIFS Connections
2025-04-13 07:38:47 -06:00
Roger Far
2e028ed17c
Merge pull request #746 from Cucumberrbob/refactor/get-download-info
refactor: use `GetDownloadInfo` - a richer version of `GetDownloadLinks`
2025-04-13 07:37:57 -06:00
Roger Far
ba8dacd01c no message 2025-04-13 07:37:17 -06:00
Cucumberrbob
c34eaf2968
Add release dates for versions v2.0.103 and 104 to changelog 2025-04-12 15:30:45 +01:00
Cucumberrbob
8117d61566
🤦 remember to update the assembly version number when releasing
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-04-12 15:21:13 +01:00
Cucumberrbob
80da992d5d
Prepare changelog for v2.0.103
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-04-12 15:07:57 +01:00
Cucumberrbob
84d144ed7b
[PM] Return empty list, not null when all files filtered out in GetDownloadLinks 2025-04-09 10:41:50 +01:00
Cucumberrbob
a660a86bb4
Add release notes for#776 2025-04-09 09:09:27 +01:00
Cucumberrbob
f5317de9de
Merge pull request #776 from EugeneKallis/main
Adding a select all button for the delete torrent popup
2025-04-09 08:59:45 +01:00
Eugene Kallivrousis
ef4245006c removing comment 2025-04-08 19:05:20 -04:00
Eugene Kallivrousis
282ebe4a5e updating port 2025-04-08 19:04:06 -04:00
Eugene Kallivrousis
b0676d31d2 reverting proxy change 2025-04-08 19:03:15 -04:00
Eugene Kallivrousis
158f4587e2 Updating as per comments from pr 2025-04-08 19:02:48 -04:00
Cucumberrbob
953e208b18
run prettier 2025-04-08 20:49:06 +01:00
Eugene Kallivrousis
6f67f38600 Adding a select all button for the delete torrent popup 2025-04-08 15:20:56 -04:00
Cucumberrbob
aa3f24ff50
Return number of selected files from SelectFiles so we can handle all files being excluded 2025-03-27 12:01:53 +00:00
Cucumberrbob
3f4fb6d20a
Merge pull request #766 from nagromc/patch-1
docs: fix level for Synology Download Station
2025-03-25 17:10:46 +00:00
Morgan Courbet
d267a3451a
docs: fix level for Synology Download Station 2025-03-24 13:26:38 +01:00
Cucumberrbob
3780a954a2
add setting to change RD api hostname 2025-03-23 14:08:27 +00:00
Cucumberrbob
99ea3f008f
fix merge (again!) 2025-03-23 13:32:37 +00:00
Cucumberrbob
5a1b96b909
Merge remote-tracking branch 'origin/main' into fork/Cucumberrbob/refactor/get-download-info
# Conflicts:
#	server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs
#	server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs
#	server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs
2025-03-23 13:30:05 +00:00
Roger Far
9841ab6399 no message 2025-03-21 16:48:55 -06:00
Roger Far
720696f5ec no message 2025-03-21 16:40:21 -06:00
Roger Far
2fbfc43f6c no message 2025-03-21 16:36:11 -06:00
Roger Far
38c8c57672 no message 2025-03-21 16:29:21 -06:00
Roger Far
f70ddb87b6 no message 2025-03-21 16:28:03 -06:00
Roger Far
c7020f1056 Tests to make releasing easier and more automated. 2025-03-21 16:26:07 -06:00
Roger Far
605868fe70 Changed to get the version from the API. 2025-03-21 16:02:45 -06:00
Roger Far
53a1407ef8 Angular cleanup. 2025-03-21 15:31:34 -06:00
Roger Far
b3f45ece20 Upgrade to latest packages, ran prettier. 2025-03-21 15:17:29 -06:00
Roger Far
091b976b9b Update packages. 2025-03-21 15:10:54 -06:00
Roger Far
83cd14f807
Merge pull request #761 from Cucumberrbob/fix/close-mobile-menu-on-navigation
fix: close mobile menu after navigating
2025-03-21 15:08:04 -06:00
Roger Far
7340fb3183
Merge pull request #760 from Cucumberrbob/fix/word-break-long-torrent-names
add `word-break: break-all` to torrent name `td`
2025-03-21 15:07:31 -06:00
Roger Far
3d4e656121
Merge pull request #755 from Cucumberrbob/perf/providerupdater-dont-update-when-all-rdfinished
`ProviderUpdater`: don't run if all torrents finished and `AutoImport == false`
2025-03-21 15:07:12 -06:00
Roger Far
2eddeaecb7
Merge pull request #747 from Cucumberrbob/feat/ignore-updates
feat: Allow ignoring update notifications (but notify anyway if GHSA found)
2025-03-21 15:04:37 -06:00
Roger Far
67b699605a
Merge pull request #744 from Cucumberrbob/fix/setup-provider-auth
fix: require auth to setup provider
2025-03-21 14:59:52 -06:00
Roger Far
d4d2689772
Merge pull request #742 from Cucumberrbob/fix/proxy-server-is-bezzad-not-internal
fix: typo - proxyserver setting applies to bezzad not internal
2025-03-21 14:57:56 -06:00
Roger Far
2be825e248
Merge pull request #741 from Cucumberrbob/feat/ad-rd-exclude-all-fail-torrent
feat: [AD] [RD] don't download anything when all files excluded
2025-03-21 14:14:18 -06:00
Roger Far
0b7749376a
Merge pull request #735 from Cucumberrbob/fix/tb-include-version-in-user-agent
include version when setting torbox api client useragent
2025-03-21 14:12:30 -06:00
Cucumberrbob
7eb1a610b4
use ternary not (Boolean) switch 2025-03-21 13:35:51 +00:00
Cucumberrbob
74e6d2cf95
don't add torrents straight to debrid provider, add to queue first, dequeue in TorrentRunner 2025-03-21 13:09:59 +00:00
Cucumberrbob
9fa0252624
fix: close mobile menu after navigating 2025-03-20 15:48:51 +00:00
Cucumberrbob
4459706e79
add word-break: break-all to torrent name td
wraps text rather than making a horizontal scroll bar
2025-03-20 15:33:08 +00:00
Cucumberrbob
e9c4544d34
ProviderUpdater: don't run if all torrents finished and AutoImport == false 2025-03-17 20:50:09 +00:00
Cucumberrbob
ab5a013edb
[TB] use torrentClientTorrent (if it's passed) instead of fetching in UpdateData
Makes `ProviderUpdater` only cause 2 API requests each run rather than `O(n)` where `n` is the number of torrents
2025-03-17 20:24:31 +00:00
Cucumberrbob
e6f7db0763
fix merge 2025-03-16 15:49:46 +00:00
Cucumberrbob
609c67e0c6
Merge branch 'master' into refactor/get-download-info 2025-03-16 15:46:36 +00:00
Cucumberrbob
aa6e6c7df5
Add release notes for #749 2025-03-16 15:38:39 +00:00
Cucumberrbob
b4bb775617
Merge pull request #749 from asylumexp/fix/tb-download-as-zip
[TB] Add downloading via zip functionality
2025-03-16 15:34:55 +00:00
Cucumberrbob
93fac23128
run dotnet format 2025-03-16 15:34:25 +00:00
Sam Heinz
afc7d1a964 [TB] fix: replace Contains with EndsWith 2025-03-17 00:50:51 +10:00
Sam Heinz
7a822bef5c [TB] fix: extract folder move logic 2025-03-17 00:28:19 +10:00
Sam Heinz
7d1aeed85c
[TB] fix: getting provider in UnpackClient
Co-authored-by: Cucumberrbob <128094686+Cucumberrbob@users.noreply.github.com>
2025-03-16 23:33:17 +10:00
Sam Heinz
bd96795f34 [TB] fix: moving hash dir files too far up 2025-03-16 20:45:34 +10:00
Sam Heinz
9d57466472 [TB] fix: download urls still not returning filename via HEAD. 2025-03-16 20:45:34 +10:00
Sam Heinz
fd7bc529c8 [TB] fix: move MoveHashDirectoryUpTB to TorBoxTorrentClient 2025-03-16 20:45:34 +10:00
Sam Heinz
bf023b333b [TB] fix: run MoveHashDirectoryUpTB only once
Signed-off-by: Sam Heinz <54530346+asylumexp@users.noreply.github.com>
2025-03-16 20:45:30 +10:00
Sam Heinz
7019c79814
[TB] fix: CLR type names in UnpackClient
Co-authored-by: Cucumberrbob <128094686+Cucumberrbob@users.noreply.github.com>
2025-03-15 22:20:50 +10:00
Sam Heinz
6b211c6e7c [TB] fix: getting provider in UnpackClient
Co-Authored-By: Cucumberrbob <128094686+Cucumberrbob@users.noreply.github.com>
2025-03-15 22:19:58 +10:00
Sam Heinz
9525498671 [TB] fix: get files from torrent name folder in TB zip 2025-03-13 00:35:42 +10:00
Sam Heinz
edf443adfc [TB] fix: move files from hash folder in TB zips to root download dir 2025-03-13 00:24:43 +10:00
Sam Heinz
10176357e4 [TB] fix: unpacking zip downloads
may have unintended consequences by fixing this as it may now unpack zips in individual file downloads that aren't intended to be unpacked?
2025-03-12 23:05:19 +10:00
Sam Heinz
ea9c90552b [TB] fix: timeout to use settings timeout val 2025-03-12 22:50:18 +10:00
Sam Heinz
087653e5b6 [TB] fix: calling fileFilter twice in getdllinks 2025-03-12 22:27:48 +10:00
Sam Heinz
85a0e7b04f [TB] fix nit about bool -> var 2025-03-12 22:18:15 +10:00
Sam Heinz
dc9c7ff050 [TB] Add downloading via zip functionality 2025-03-12 18:19:24 +10:00
Cucumberrbob
469f1dbbad
Merge pull request #748 from rogerfar/Cucumberrbob-fix-typo-bug-report-template
Fix: typo in bug report issue template
2025-03-11 20:44:38 +00:00
Cucumberrbob
b6cd6ceddd
Fix typo in bug report issue template 2025-03-11 20:44:11 +00:00
Cucumberrbob
f68911e9c2
Add UpdateChecker.IsInsecure to check if running version is insecure
Uses a hard-coded list of GHSA ids of known fixed vulnerabilities
2025-03-11 20:38:02 +00:00
Cucumberrbob
b333c738be
Add DisableUpdateNotifications setting
Lets the user turn notifications off completely
2025-03-11 20:34:52 +00:00
Cucumberrbob
d6efff2fb5
add rest of xmldoc for DownloadInfos 2025-03-11 12:44:48 +00:00
Cucumberrbob
c50afe9e64
[TB] GetDownloadLinks -> GetDownloadInfos 2025-03-11 12:14:15 +00:00
Cucumberrbob
0cc15d26c3
[RD] GetDownloadLinks -> GetDownloadInfos 2025-03-11 12:14:09 +00:00
Cucumberrbob
7615883fdb
[PM] GetDownloadLinks -> GetDownloadInfos 2025-03-11 12:14:04 +00:00
Cucumberrbob
2b2c5462f1
[DL] GetDownloadLinks -> GetDownloadInfos 2025-03-11 12:13:44 +00:00
Cucumberrbob
88af15b3bc
[AD] GetDownloadLinks -> GetDownloadInfos
Also makes the tests work wiith this new method
2025-03-11 12:13:24 +00:00
Cucumberrbob
e776236270
Use richer GetDownloadInfos instead of GetDownloadLinks
*Most* debrid providers know the filename as soon as they know the restricted links. By allowing them to tell the program this at an earlier stage, we don't need the `GetFileName` call for most debrid providers
2025-03-11 12:12:16 +00:00
Cucumberrbob
983e357a01
fix: require auth to setup provider 2025-03-09 14:50:51 +00:00
Cucumberrbob
d4ba114772
fix: typo - proxyserver setting applies to bezzad not internal 2025-03-09 12:45:51 +00:00
Cucumberrbob
f21fd6d8f3
Add dotnet test GitHub Action 2025-03-08 13:46:41 +00:00
Cucumberrbob
2ae3b85dac
[RD] mark torrent failed when all files excluded by filters 2025-03-08 13:46:07 +00:00
Cucumberrbob
097ea93410
[AD] mark torrent failed when all files excluded by filters 2025-03-08 13:46:02 +00:00
Cucumberrbob
f8587d1bc8
fixup! include version when setting torbox api client useragent 2025-03-07 22:40:02 +00:00
Cucumberrbob
818d9f06b2
include version when setting torbox api client useragent 2025-03-07 22:26:18 +00:00
Roger Versluis
a366328472 Fixed docker angular build.
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-07 13:48:14 -07:00
Roger Far
193b195218 Fixed Angular build.
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-07 13:28:47 -07:00
Roger Far
207767dbce Fixed Angular build.
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-07 12:39:15 -07:00
Roger Far
309a91ace2 no message
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-07 12:33:00 -07:00
Roger Far
794eec8666 Fixed vulnerability issue where the auth Update endpoint wasn't protected. 2025-03-07 12:29:33 -07:00
Roger Far
5c987a9057
Merge pull request #732 from Cucumberrbob/feat/reduce-torbox-api-load
feat:  [TB] Set user-agent
2025-03-07 12:22:06 -07:00
Roger Far
13bb353644
Merge pull request #729 from Cucumberrbob/refactor/download-filtering
refactor!: DRY Download Filtering
2025-03-07 12:21:32 -07:00
Roger Far
861854b4f1
Merge pull request #725 from Cucumberrbob/deps/angular-19-update
Update to Angular 19, replace `@angular/flex-layout` with css
2025-03-07 12:18:55 -07:00
Roger Far
680904cb94
Merge pull request #721 from asylumexp/update-torboxnet
[TB] Update TorBox.NET to 1.5.0
2025-03-07 12:18:17 -07:00
Roger Far
e9becefb1c
Merge pull request #720 from Cucumberrbob/tests/use-theory-data
Use `TheoryData<>` instead of `Object[]` for theory data
2025-03-07 12:18:07 -07:00
Cucumberrbob
1034f342ed
Update torrent state to error when all files excluded by filters.
Only affects PM, TB and DL
2025-03-06 19:57:10 +00:00
Cucumberrbob
9cf28d8274
Revert "Use 10 second interval for TB ProviderUpdater"
This reverts commit 1abaeb598e.
2025-03-06 16:16:42 +00:00
Cucumberrbob
1abaeb598e
Use 10 second interval for TB ProviderUpdater 2025-03-06 13:25:14 +00:00
Cucumberrbob
0fd3f2a467
Set user agent for TB 2025-03-06 13:20:59 +00:00
Cucumberrbob
706be01a40
rename private methods of DownloadableFileFilter 2025-03-03 12:44:15 +00:00
Cucumberrbob
d40e686845
Remove logic in DLTorrentClient erroneously copied from RDTorrentClient
This code handled the RD-specific behaviour of choosing which files to download, and only getting those links from the API, rather than what DL does: downloading all files and having the api consumer pick which links they want to download.
2025-03-03 12:22:02 +00:00
Cucumberrbob
765a5b0543
Use DownloadableFileFilter in RealDebridTorrentClient to filter files 2025-03-03 12:04:34 +00:00
Cucumberrbob
2b6e51b5cf
Use DownloadableFileFilter in PremiumizeTorrentClient to filter files 2025-03-03 12:03:00 +00:00
Cucumberrbob
537845ea80
Use DownloadableFileFilter in DebridLinkTorrentClient to filter files.
Uses `TorrentClientTorrent.Files` instead of `.Links` to get download links. Also adds `DownloadLink` to `Files` when `Map`ping to `TorrentClientTorrent`
2025-03-03 12:00:33 +00:00
Cucumberrbob
2944101657
Use DownloadableFileFilter in AllDebridTorrentClient to filter files
Also updates unit tests to not test if filter works. Test filter is used appropriately instead.
2025-03-03 11:57:43 +00:00
Cucumberrbob
816c72f418
Use DownloadableFileFilter in TorBoxTorrentClient to filter files 2025-03-03 11:55:26 +00:00
Cucumberrbob
a02fcc6c1e
Inject DownloadableFileFilter in Service.DiConfig 2025-03-03 11:54:01 +00:00
Cucumberrbob
b95dbcc824
Add DownloadableFileFilter + unit tests
`DownloadableFileFilter` is an abstraction of the filtering we do in most of the `TorrentClient`s.
It operates on a single file at a time, not the entire torrent.
2025-03-03 11:52:19 +00:00
Cucumberrbob
7d8e6eb788
🤦 revert changes to angular-proxy.json 2025-02-28 13:21:35 +00:00
Cucumberrbob
0dc9d687b9
Uninstall flex layout 2025-02-28 13:10:56 +00:00
Cucumberrbob
8d7552609b
replace @angular/flex-layout in torrent-table.component
Use `class="flex-container"` as in `torrent.component`
2025-02-28 13:06:41 +00:00
Cucumberrbob
d11900a127
replace @angular/flex-layout in torrent-component
use `flex-container` class instead of `fxLayout` and set `style="flex: 1 1 0" to replicate `fxFlex`
2025-02-28 13:04:28 +00:00
Cucumberrbob
4a5be33247
remove @angular/flex-layout from add-torrent-component - doesn't appear to change the layout at any vw 2025-02-28 12:45:51 +00:00
Cucumberrbob
91ee1f8462
use @forward not @import for importing styles 2025-02-28 12:38:20 +00:00
Cucumberrbob
e06c1efb6a
angular 18 -> 19 + migrations 2025-02-28 12:28:21 +00:00
Cucumberrbob
b4f950bc7c
angular 17 -> 18 + migrations 2025-02-28 12:13:55 +00:00
Cucumberrbob
49e3155f9c
update ngx-filesize 2025-02-28 12:10:26 +00:00
Cucumberrbob
2846be3a33
angular 16 -> 17 2025-02-28 12:09:43 +00:00
Sam Heinz
6b7e9b2943
[TB] Update TorBox.NET to 1.5.0 2025-02-24 08:43:50 +10:00
Cucumberrbob
d683c45dbd
tests: use theorydata 2025-02-23 18:54:31 +00:00
Roger Far
85d6b820f3 Upgrade packages, fixed few formatting issues. 2025-02-23 11:42:38 -07:00
Roger Far
3fc44163a9
Merge pull request #719 from Cucumberrbob/chore/run-prettier
Run prettier
2025-02-23 11:27:07 -07:00
Roger Far
7436e2d036
Merge pull request #715 from Cucumberrbob/fix/use-getsymlinkpath-method
Actually use `GetSymlinkPath` logic
2025-02-23 11:24:23 -07:00
Roger Far
115a1dcc56
Merge pull request #709 from Wald764/pr-main-repo
⬆️ DebridLinkFr.NET to 1.0.4
2025-02-23 11:23:58 -07:00
Roger Far
a4b95923b2
Merge pull request #708 from Cucumberrbob/fix/pm-nested-files
[PM] fix: search subdirectories using `item.Id` not `item.FolderId`
2025-02-23 11:23:38 -07:00
Roger Far
f0310e2b00
Merge pull request #707 from Cucumberrbob/fix/existing-file-in-error-processed-watch-path
Delete file if exists when copying to `Error Path` or `Processed Path` (watch folder)
2025-02-23 11:23:07 -07:00
Roger Far
ee62bc3098
Merge pull request #691 from Cucumberrbob/feat/unit-tests
feat: Add unit tests
2025-02-23 11:22:20 -07:00
Cucumberrbob
206d3c56cb
Remove unused import 2025-02-23 17:29:07 +00:00
Cucumberrbob
0ec9a2bbbe
Run npm run prettier 2025-02-23 17:28:26 +00:00
Cucumberrbob
d292fbaee8
remove superfluous newline 2025-02-20 14:59:09 +00:00
Cucumberrbob
8e1f61472a
remove commented out line 2025-02-20 14:58:37 +00:00
Cucumberrbob
439b266946
Actually use GetSymlinkPath 2025-02-20 14:57:44 +00:00
Wald764
66f0ae0e7c ⬆️ DebridLinkFr.NET to 1.0.4 2025-02-18 18:38:07 +01:00
Cucumberrbob
5a78125053
[PM] fix: search subdirectories using item.Id as folderId when getting downloaod links 2025-02-18 11:51:30 +00:00
Cucumberrbob
29914138aa
cleanup 2025-02-17 23:31:13 +00:00
Cucumberrbob
c43be84507
Delete file if exists when copying to {error,processed}StorePath (watch folder) 2025-02-17 23:03:38 +00:00
Cucumberrbob
1d15b0d651
Add tests for AllDebridTorrentClient
Tests:
- `GetDownloadLinks`
  - `IncludeRegex` filter works
  - `ExcludeRegex` filter works
  - `DownloadMinSize` filter works
  - only `IncludeRegex` is used when both `IncludeRegex` and `ExcludeRegex` are set
  - gets links for all files if none of the above filters are set
  - returns null if `torrent.RdId` is null
- `GetTorrents`
  - [Both full/partial sync] returns empty list when no torrents present in api response
  - [full sync] overrides any previous torrents in cache, deleting those not present in the api response
  - [partial sync] updates torrents in cache according to the api response
  - [partial sync] adds torrents to cache according to the api response
  - `Map` (private so tested via `GetTorrents`)
    - Computes progress correctly from `Downloaded / Size`
  - `UpdateData`
    - [`torrentClientTorrent != null`] does not get from api, updates the `torrent` accordingly
    - [`torrentClientTorrent == null`] gets from api, updates the `torrent` accordingly
    - [`torrentClientTorrent == null`] when torrent deleted from rd, updates `RdStatusRaw = "deleted""`
    - when `torrent.RdId == null`, returns the `torrent` unmodified
2025-02-17 22:04:18 +00:00
Cucumberrbob
e0264a6e29
Add tests for DownloadHelper (GetDownloadPath)
Tests:
- [Both overloads] uses the `download.Link` to guess the filenamem if it's not present
- [Both overloads] returns null if `download.Link` is null
- [Both overloads] returns nuyll if `download.RdName` is null
- [Both overloads] works for a file in a deeply nested subdirectory
- [overload with `downloadPath` argument only] creates the correct directory
2025-02-17 22:01:53 +00:00
Cucumberrbob
95ef1f42b8
Add tests for DownloadStationDownloader
Tests:
- Downloads successfully when everything goes right first time
- Downloads successfully when the 5th api call is successful after 4 failures
- Fails to download when 5 api calls fail in a row
- Fails to download if the file has already been added to `Download Station` (when `GetInfoAsync` does not error)
- FAils to download if `remotePath` cannot be parsed by `Path.GetDirectoryName()`
2025-02-17 22:01:16 +00:00
Cucumberrbob
f4ca137e83
Add tests for Torrents#RunTorrentComplete
Tests:
- doesn't run command when not present
- does run command when present
- listens to events on process for stdout/stderr
2025-02-17 22:00:57 +00:00
Cucumberrbob
3dee900be6
Add test project RdtClient.Service.Test 2025-02-17 22:00:33 +00:00
Cucumberrbob
2bbbf0fd10
Use ISynologyClient (mockable) and inject IDelayProvider so SynologyTorrentClient is testable 2025-02-17 22:00:19 +00:00
Cucumberrbob
c546bd0173
Use IProcessFactory, IFileSystem in Service.Torrents#RunTorrentComplete
Also adds dependency injection to `Torrents#RunTorrentComplete` for `DbSettings` (`Settings.Get`)
2025-02-17 21:59:56 +00:00
Cucumberrbob
fd073b4762
Add interfaces for TorrentData, Downloads, Process, ProcessFactory
Making them interfaces lets us dependency inject them, allowing a given class to be tested.
2025-02-17 21:57:29 +00:00
Cucumberrbob
daf8a9c6da
DownloadHelper: use IFileSystem for testable filesystem access 2025-02-17 21:57:02 +00:00
Cucumberrbob
95307f2933
Use AllDebridNetClientFactory so AD client can be dependency-injected 2025-02-17 21:56:32 +00:00
Roger Far
fe241db5db Upgrade AllDebrid and Downloader.NET to the latest versions.
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-02-16 09:44:21 -07:00
Roger Far
7c3c1fb24c
Merge pull request #692 from Cucumberrbob/fix/include-exclude-pm-tb
[PM] [TB] Fix: Implement `IncludeRegex`, `ExcludeRegex`, and `DownloadMinSize`
2025-02-16 09:30:19 -07:00
Cucumberrbob
9299314b14
Log when rejecting files in TB GetDownloadLinks 2025-02-13 11:50:40 +00:00
Cucumberrbob
e5e64e6946
Include torrent in logs when rejecting files in PM GetDownloadLinks 2025-02-13 11:41:41 +00:00
Roger Far
1ac325e8c9
Merge pull request #689 from Cucumberrbob/docs/downloadstation-initial
Update `readme` to include Synology Download Station.
2025-02-12 20:35:34 -07:00
Roger Far
3b8f14cc1a
Merge pull request #688 from Cucumberrbob/docs/torbox-initial
Update readme to include `TorBox` in debrid services
2025-02-12 20:35:17 -07:00
Roger Far
311156f0aa
Merge branch 'master' into docs/torbox-initial 2025-02-12 20:35:06 -07:00
Roger Far
f8505edf7f no message 2025-02-12 20:34:24 -07:00
Roger Far
09d929db17
Merge branch 'master' into docs/torbox-initial 2025-02-12 20:32:32 -07:00
Roger Far
f57ee1e53d
Merge pull request #694 from asylumexp/patch-2
[TB] Update TorBox.NET to v1.4.0
2025-02-12 20:27:43 -07:00
Roger Far
56c25770c3 Cleanup. 2025-02-12 20:27:29 -07:00
Roger Far
7b4facd46f
Merge pull request #680 from Wald764/pr-main-repo
🎉 add support of DebridLinkFr
2025-02-12 20:25:02 -07:00
Roger Far
c005bda484
Merge branch 'master' into pr-main-repo 2025-02-12 20:24:54 -07:00
Roger Far
f58b7d3e72 Refactored TorrentClientKind to Provider. Upgrade Downloader.NET 2025-02-12 20:24:22 -07:00
Roger Far
780d0e5118 Add fixes for AllDebrid and their new API. 2025-02-12 19:48:48 -07:00
Sam Heinz
82b44cccde
[TB] Update TorBox.NET to v1.4.0 2025-02-08 16:40:57 +10:00
Cucumberrbob
26f1f26cd1
Ensure MinDownloadSize, IncludeRegex and ExcludeRegex are honoured by TorBoxTorrentClient 2025-02-07 14:43:59 +00:00
Cucumberrbob
9e223de32e
Ensure MinDownloadSize, IncludeRegex and ExcludeRegex are honoured by PremiumizeTorrentClient 2025-02-07 14:40:13 +00:00
Wald764
f1bdc7d8b4 ♻️ fix a reference to RD instead of DL in DebridLinkTorrentClient 2025-02-03 00:49:36 +01:00
Cucumberrbob
48766e3d44 🐛 Fix build errors by not using Torrent = in DebridLinkTorrentClient
use `Data.Models.Data.Torrent`instead of `Torrent` and `Download = RdtClient.Data.Models.Torrent`
2025-02-03 00:25:02 +01:00
Cucumberrbob
56a61ad012 Use exact path for symlink source when using SymlinkDownloader 2025-02-02 22:11:26 +01:00
Wald764
303244127b ♻️ fix typo in Torrents.cs 2025-02-02 17:50:51 +01:00
Wald764
f2207be000 📝 update readme to include DebridLink in debrid services 2025-02-02 17:35:04 +01:00
Wald764
20fbf8833a ♻️ rename occurrences of debridlinkfr to debridlink 2025-02-02 17:19:41 +01:00
Wald764
d6d1813340 🐛 implement PR suggestions 2025-02-02 17:10:41 +01:00
Wald764
7902f56d66 🐛 endpoint /seedbox/cached removed by DebridLink 2025-02-02 16:46:35 +01:00
Wald764
4198b6ab55 🐛 fixes about the providers order and addition of GetFileName to DebridLinkFrTorrentClient 2025-02-02 16:26:46 +01:00
Cucumberrbob
a9cd30364e
Update readme to include Synology Download Station. Descriptions for configuration options taken from 047326a by YvesPa 2025-02-02 15:06:27 +00:00
Cucumberrbob
ce14a1d62e
Update readme to include TorBox in debrid services 2025-02-02 14:52:29 +00:00
Wald764
59c47bfe1a 🎉 add support of DebridLinkFr 2025-01-31 10:07:29 +01:00
Roger Far
dc2ec486d7 no message
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-01-29 19:13:43 -07:00
Roger Far
a566f47988 Set the download client based on the torrent instead of hardcoded when upload a torrent from the web GUI. 2025-01-29 19:10:14 -07:00
Roger Far
24c759e4e6 Small fixes in the download client. 2025-01-29 19:03:38 -07:00
Roger Far
809903eebb
Merge pull request #679 from Cucumberrbob/fix/bad-download-path
[Fix] Revert changes to `DownloadHelper`, move AD-symlink specific fixes to `AllDebridTorrentClient.GetSymlinkPath`
2025-01-29 18:59:38 -07:00
Roger Far
ef8142453f
Merge branch 'master' into fix/bad-download-path 2025-01-29 18:59:31 -07:00
Roger Far
0a705e6720
Merge pull request #678 from YvesPa/AddDate
Proposition : Add request date on table for sorting
2025-01-29 18:55:55 -07:00
Roger Far
f379d4efb8 DownloadStation cleanup. 2025-01-29 18:55:24 -07:00
Roger Far
881c74f2b7
Merge pull request #670 from YvesPa/master
Add Synology Download Station Support
2025-01-29 18:51:16 -07:00
Cucumberrbob
0c9de86531
rename symlinkPath -> symlinkSourcePath 2025-01-29 12:19:52 +00:00
Cucumberrbob
82f0bb8bbe
Use static GetSymlinkPath on AllDebridTorrentClient to get the symlink (since the behaviour is unique to the debrid service) 2025-01-29 12:11:25 +00:00
Cucumberrbob
beb0299a0b
revert changes to DownloadHelper.cs 2025-01-29 11:07:20 +00:00
YvesPa
1e749a9bfe Add request date on table for sorting 2025-01-28 15:08:32 +01:00
YvesPa
062787143f Throw an lisible error if not config 2025-01-28 14:12:37 +01:00
YvesPa
eeef6fb7fe Merge remote-tracking branch 'rogerfar/master'
# Conflicts:
#	server/RdtClient.Service/RdtClient.Service.csproj
#	server/RdtClient.Service/Services/DownloadClient.cs
2025-01-22 19:11:23 +01:00
YvesPa
85e07f3c90 Update to nuget Synology project 2025-01-22 17:42:04 +01:00
Roger Far
db1760b36e Update to .95.
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-01-19 20:47:09 -07:00
Roger Far
ce223d3e2c Add qBittorrent endpoint transfer/info. 2025-01-19 20:43:28 -07:00
Roger Far
3ed80909de Upgrade packages. 2025-01-19 19:58:29 -07:00
Roger Far
b8c183c079 Refactored out merge pattern for more readable syntax. 2025-01-19 19:57:39 -07:00
Roger Far
e449bd7928 Refactored out goto statement. 2025-01-19 19:55:19 -07:00
Roger Far
353b9ca07b
Merge pull request #652 from Sculas/fix/path-single-file-torrent
fix: Improve path fixes for Alldebrid & only apply them to Alldebrid
2025-01-19 19:51:01 -07:00
Roger Far
b33ecd6b1c
Merge pull request #653 from Cucumberrbob/master
Use local files when building docker image
2025-01-19 19:49:27 -07:00
YvesPa
735912da5d Change log position 2025-01-10 16:45:50 +01:00
YvesPa
7d2d7fa35f Merge remote-tracking branch 'rogerfar/master' 2025-01-10 16:27:04 +01:00
Sculas
00e499593e
fix: Fix Alldebrid path again 2025-01-09 16:23:56 +01:00
Sculas
d6305c26f8
fix: Only add path separator in specific cases 2025-01-08 13:41:47 +01:00
Sculas
56934e3183
refactor: Improve logging 2025-01-07 15:15:31 +01:00
Sculas
38a3bc1537
perf(SymlinkDownloader): Skip file search when using Alldebrid 2025-01-07 14:34:02 +01:00
Sculas
8fe277fa52
fix(SymlinkDownloader): Add search path to potential file paths 2025-01-07 14:26:26 +01:00
Cucumberrbob
7b97ab8cd2
Use local files when building docker image instead of cloning repo from github 2025-01-06 18:53:26 +00:00
Sculas
ee9e80f7d2
fix: Only apply AllDebrid-specific path fixes to AllDebrid 2025-01-06 12:41:41 +01:00
Roger Far
d5ee5d209c Changed how files are saved. 2025-01-05 20:56:37 -07:00
Roger Far
d43774d9b5 Fix hanging Aria2 downloads when files are being copied or cannot be found on the host. 2025-01-05 20:49:55 -07:00
Roger Far
e700939cfa
Merge pull request #651 from Sculas/fix/path-single-file-torrent
fix: Fix invalid download path with single-file torrents
2025-01-05 15:21:05 -07:00
Sculas
268bb6826c
fix: Fix invalid download path with single-file torrents 2025-01-05 22:17:22 +01:00
Roger Far
79164d54ca no message
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-01-05 11:03:18 -07:00
Roger Far
c958b4960b
Merge pull request #650 from Sculas/fix/ad-filters
fix(AllDebrid): Implement regex filters & preserve folder structure
2025-01-05 11:01:59 -07:00
Sculas
0026541dca
fix(AllDebrid): Implement filters & fix file paths 2025-01-04 04:29:26 +01:00
Roger Versluis
8518cc1e10 no message
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-01-03 15:54:55 -07:00
Roger Versluis
34aec3d45d no message 2025-01-03 15:54:43 -07:00
Roger Far
8205e0e652
Merge pull request #645 from asylumexp/fix/torbox-availability
[TB] Fix Index was out of range error #644
2025-01-03 15:53:07 -07:00
Sam Heinz
409e47920e Revert "[TB] Download via zip if file count is over 50"
This reverts commit 6afef14855.
2025-01-02 21:21:52 +10:00
Sam Heinz
fa0f535858 [TB] Update TorBox.NET to 1.3.2 2025-01-02 13:15:59 +10:00
Sam Heinz
065afe9461 [TB] Set seeding based on website seeding setting
The default seed value for website dash is used to set whether to seed or not for new uncached torrents added via rdtclient
2025-01-02 13:15:42 +10:00
Sam Heinz
6afef14855 [TB] Download via zip if file count is over 50
Due to rate limiting issues this is implemented again, it works now since TB reenabled HEAD method for zip downloads on their backend
2025-01-02 13:10:36 +10:00
Sam Heinz
78daeeeaf3 [TB] Update TorBox.NET to 1.3.0 2024-12-28 16:52:45 +10:00
Sam Heinz
e21dc7279a [TB] Fix Index was out of range error
put < when shouldve put >
2024-12-28 01:36:21 +10:00
Roger Versluis
9a2d59afe0 no message
Some checks failed
Docker Image CI / build (push) Has been cancelled
2024-12-18 07:23:44 -07:00
Roger Far
f5c528645d
Merge pull request #637 from asylumexp/patch-1
[TB] Fix source parameter null log spam #618
2024-12-18 07:19:04 -07:00
Sam Heinz
1ad3dc51dd
[TB] Fix source parameter null log spam #618 2024-12-12 17:46:46 +10:00
Roger Far
ea364eefc5 Cleanup, bump to 91.
Some checks failed
Docker Image CI / build (push) Has been cancelled
2024-12-11 20:15:25 -07:00
Roger Far
98c4b2ae9b Merge branch 'master' of https://github.com/rogerfar/rdt-client 2024-12-11 20:13:46 -07:00
Roger Far
1a27be8309 Update packages. 2024-12-11 20:13:42 -07:00
Roger Far
adc976067d
Merge pull request #636 from asylumexp/fix/torbox-filenames
Fix for TorBox incorrect filenames
2024-12-11 20:11:31 -07:00
Sam Heinz
e92b197ddb [TB] Fix UI errors 2024-12-11 18:59:21 +10:00
Sam Heinz
ac6300a504 [TB] Replace null with empty string for filenames in TorrentClient
Not sure if its better to do this or allow null value to db, but i had this originally and it worked so i think im better off leaving it for now, but idk.

Also got fix for incorrect log
2024-12-11 00:03:43 +10:00
Sam Heinz
9d03c20aaa [TB] Change DownloadHelper to use download.FileName
Also adds the UpdateFileName function to DownloadHelper & DownloadData to push FileName to DB
2024-12-10 23:47:55 +10:00
Sam Heinz
350185b702 [TB] Refactor GetFileName in Torrent Clients 2024-12-10 23:45:13 +10:00
Sam Heinz
a1599ee716 [TB] Add FileName value to Download model 2024-12-10 23:44:11 +10:00
YvesPa
f9335be163 Merge remote-tracking branch 'rogerfar/master' 2024-12-10 11:12:55 +01:00
YvesPa
346982fe8d Manage error 2024-12-10 11:05:39 +01:00
Sam Heinz
3364deecbb [TB] Add download state for stalledDL 2024-12-10 15:25:45 +10:00
Sam Heinz
dffce96a45 [TB] Add default for fileName in DownloadHelper 2024-12-10 15:25:30 +10:00
Sam Heinz
c34c62f879 [TB] Move handling filenames from DownloadHelper to TorrentClients 2024-12-10 15:10:55 +10:00
Roger Far
1a9a0ddf3c Update README to include the ASP.NET Core Runtime. 2024-12-06 18:18:04 -07:00
YvesPa
5e52e2dd8c Use local Synology for test 2024-10-17 14:32:53 +02:00
YvesPa
c562e75a97 correct Path with category 2024-10-15 21:48:48 +02:00
YvesPa
d15b1a2c01 some changes for tests 2024-10-15 18:27:11 +02:00
YvesPa
c6beedf675 Correct RealDebrid renaming sometimes 2024-09-25 18:07:34 +02:00
YvesPa
fcbd9d8096 Correct Path using qbittorrent 2024-09-25 14:51:37 +02:00
YvesPa
047326a30f Add Synology Download Station 2024-09-23 12:51:04 +02:00
Bryan Reagan
337b70ba01
Rootless Podman and CIFS Connections
First, thanks for the awesome utility!

Some feedback on getting the client running on Arch using rootless Podman.  Works OOB with normal paths on the host and following the tips from LinuxServer.io, but some funny errors working with paths available through a CIFS mount.  

Still having some bugs I'm working out, but thought to get  a PR going if you thought these notes would be helpful to others.  These issues seem like an edge case; let me know if you want issues created for these.

## Issues
- [x] Access Denied Errors in Web GUI Tests ([dotnet runtime issue link](https://github.com/dotnet/runtime/issues/42790))
- [ ] Directory Does Not Exist To Extract To
2021-10-15 10:11:50 -05:00
239 changed files with 26597 additions and 30465 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

View file

@ -9,7 +9,7 @@ assignees: ''
**What version are you using?**
**Wat OS are you running?**
**What OS are you running?**
**Are you using Docker or as a service?**

141
.github/workflows/build-docker-image.yml vendored Normal file
View file

@ -0,0 +1,141 @@
name: Release Docker Image
on:
push:
tags:
- v*
permissions:
packages: write
env:
APP_NAME: rdtclient
DOCKER_FILE: ./Dockerfile
ENABLE_DOCKERHUB: 1
ENABLE_GHCR: 1
concurrency: ${{ github.sha }}
jobs:
build:
outputs:
digest-amd64: ${{ steps.digest.outputs.digest-amd64 }}
digest-arm64: ${{ steps.digest.outputs.digest-arm64 }}
strategy:
matrix:
config:
- arch: amd64
runs-on: ubuntu-latest
platform: linux/amd64
- arch: arm64
runs-on: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.config.runs-on }}
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Extract version and patch .csproj
run: |
VERSION="${GITHUB_REF_NAME#v}"
sed -i "s|<Version>.*</Version>|<Version>$VERSION</Version>|" server/RdtClient.Web/RdtClient.Web.csproj
sed -i "s|<AssemblyVersion>.*</AssemblyVersion>|<AssemblyVersion>$VERSION</AssemblyVersion>|" server/RdtClient.Web/RdtClient.Web.csproj
- name: Login to DockerHub
if: ${{ env.ENABLE_DOCKERHUB == 1 }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
if: ${{ env.ENABLE_GHCR == 1 }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker Metadata action
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}
ghcr.io/${{ github.repository_owner }}/${{ env.APP_NAME }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
id: digest
run: |
echo "digest-${{ matrix.config.arch }}=${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT"
push-images:
needs: build
runs-on: ubuntu-latest
steps:
- name: Login to DockerHub
if: ${{ env.ENABLE_DOCKERHUB == 1 }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
if: ${{ env.ENABLE_GHCR == 1 }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker Metadata action
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}
ghcr.io/${{ github.repository_owner }}/${{ env.APP_NAME }}
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Create manifest list and push
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}@${{ needs.build.outputs.digest-amd64 }} \
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}@${{ needs.build.outputs.digest-arm64 }}
- name: Checkout code
uses: actions/checkout@v6
- name: Docker Hub Registry Description
if: ${{ env.ENABLE_DOCKERHUB == 1 }}
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}
short-description: |
A web interface to manage your torrents on debrid providers.
readme-filepath: ./README-DOCKER.md

99
.github/workflows/build-release.yaml vendored Normal file
View file

@ -0,0 +1,99 @@
name: Create GitHub Release
on:
push:
tags:
- v*
permissions:
contents: write
packages: write
jobs:
build:
name: Test, Build, and Bundle
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "lts/*"
- name: Set up .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10'
- name: Install Frontend Dependencies
working-directory: client
run: npm install
- name: Build Frontend
working-directory: client
run: npm run build
- name: Restore Backend Dependencies
working-directory: server
run: dotnet restore
- name: Build and Publish Backend
working-directory: server
shell: pwsh
run: |
$v = "${{ github.ref_name }}".TrimStart('v')
dotnet build -c Release --no-restore -p:Version=$v -p:AssemblyVersion=$v
dotnet publish RdtClient.Web/RdtClient.Web.csproj -c Release --no-build -p:Version=$v -p:AssemblyVersion=$v -o ../publish
- name: Create ZIP
shell: pwsh
run: Compress-Archive -Path publish\* -DestinationPath RealDebridClient.zip
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: RealDebridClient.zip
path: RealDebridClient.zip
release:
name: Create GitHub release
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Strip 'v' from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Get latest release info
id: query-release-info
uses: release-flow/keep-a-changelog-action@v2
with:
command: query
version: ${{ steps.version.outputs.version }}
- name: Download Artifact
uses: actions/download-artifact@v4
with:
name: RealDebridClient.zip
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body: ${{ steps.query-release-info.outputs.release-notes }}
draft: false
prerelease: false
files: RealDebridClient.zip
token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,98 +0,0 @@
name: Docker Image CI
on:
push:
tags:
- v*
permissions:
packages: write
env:
APP_NAME: rdtclient
DOCKER_FILE: ./Dockerfile
DOCKER_PLATFORMS: "linux/arm64/v8,linux/amd64"
ENABLE_DOCKERHUB: 1
ENABLE_GHCR: 1
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Docker Metadata action
id: meta
uses: docker/metadata-action@v4.3.0
with:
images: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}
ghcr.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
# List of custom labels
# labels: # optional
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v2.1.0
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
with:
buildkitd-flags: --debug
- name: Docker Login
if: ${{ github.event_name != 'pull_request' && env.ENABLE_DOCKERHUB == 1 }}
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
if: ${{ github.event_name != 'pull_request' && env.ENABLE_GHCR == 1 }}
uses: docker/login-action@v2.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ${{ env.DOCKER_FILE }}
platforms: ${{ env.DOCKER_PLATFORMS }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Docker Hub Registry Description
if: ${{ env.ENABLE_DOCKERHUB == 1 }}
uses: peter-evans/dockerhub-description@v3.3.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}
short-description: |
a web interface to manage your torrents on Real-Debrid, AllDebrid or Premiumize.
readme-filepath: ./README-DOCKER.md
# - name: Github Registry Description
# if: ${{ env.ENABLE_GHCR == 1 }}
# uses: peter-evans/dockerhub-description@v3.3.0
# with:
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
# short-description: |
# A web interface to manage your torrents on Real-Debrid, AllDebrid or Premiumize.
# readme-filepath: ./README-DOCKER.md

24
.github/workflows/dotnet-test.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: dotnet test
on:
push:
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Restore dependencies
run: dotnet restore server
- name: Build
run: dotnet build --no-restore server
- name: Test
run: dotnet test --no-build --verbosity normal server

1
.gitignore vendored
View file

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

View file

@ -4,6 +4,288 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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.
- Improved rate limiting handling.
## [2.0.123] - 2026-02-21
### Changed
- Reverted SharpCompress to 0.42.1 due to file locking issues.
## [2.0.122] - 2026-02-21
### Added
- Sort torrents by added date by default, remember sort order in the browser.
### Changed
- Fixed ETA calculations.
- Fixed Torrentbox statuses.
- Fixed download progress reports.
- Fixed NZB delete actions.
## [2.0.121] - 2026-02-18
### Added
- NZB/Usernet support thanks to @omgbeez!
### Changed
- Make some qBittorrent endpoints accessible without authentication.
- Improved ETA calculations.
- Few performance improvements when the download page has a lot of torrents.
- Performance improvements for the sqlite connection.
## [2.0.120] - 2026-02-11
### Changed
- Upgrade to .NET 10.
- Upgrade to Angular 21.
## [2.0.119] - 2025-10-13
### Removed
- Removed internal downloader from the GUI.
## [2.0.118] - 2025-10-06
### Added
- Added some fake qBittorrent API calls for decluttarr.
## [2.0.117] - 2025-10-06
### Removed
- Removed internal downloader and migrated to the Bezzad downloader.
## [2.0.116] - 2025-08-04
### Added
- Added setting to ban certain trackers from being added. Will filter by the torrent source or announcement urls.
### Changed
- Upgraded to Angular 20.
## [2.0.115] - 2025-07-28
### Added
- Added setting to delay the finish action.
### Fixed
- Make sure the Real-Debrid provider times out when trying to add a new torrent.
## [2.0.114] - 2025-06-21
### Added
- Add Select All functionality to the delete dialog in individual torrent screen, thanks @mentalblank
- Add setting to add a list of trackers (from a URL) to every torrent and magnet that's added to rdt-client, thanks @mentalblank
### Changed
- The `User-Agent` header is now set on all requests to debrid providers' APIs.
## [2.0.113] - 2025-05-22
### Fixed
- Revert Synolog.Api.Client because of breaking changes.
## [2.0.112] - 2025-05-18
### Added
- Add ability to disable the built in unpacking process by setting the "Maximum unpack processes" to 0.
### Changed
- Upgraded Angular to use control flow.
### Fixed
- Fixed dequeing issue.
- Fixed logging handler for ProviderUpdater.
## [2.0.111] - 2025-05-03
### Added
- Added button to register rdt-client as a handler for magnet links on [supported browsers](https://caniuse.com/mdn-api_navigator_registerprotocolhandler_scheme_parameter_magnet).
## [2.0.110] - 2025-04-24
### Fixed
- Fixed build number in the app.
## [2.0.109] - 2025-04-23
### Fixed
- Debrid Queue fixes (don't auto delete queued torrents, handle errors when dequeueing).
- Censor download station password when logging settings at startup.
- Set `HostDownloadAction` when auto-importing torrents.
- Build GitHub release .zip on windows not linux.
- Use arm GitHub Actions runner to build arm docker image.
## [2.0.108] - 2025-04-13
### Fixed
- Fixed websocket UI updating.
## [2.0.107] - 2025-04-13
### Fixed
- Fixed Docker release versioning.
## [2.0.106] - 2025-04-13
### Fixed
- Changed how the GitHub release is created and how the changelog is generated.
## [2.0.105] - 2025-04-13
### Added
- Add feature to limit the amount of torrents that get sent to the provider at the same time.
### Fixed
- Moved the websocket update process to its own background thread to improve UI update consistency.
## [2.0.104] - 2025-04-12
### Fixed
- Update the version number
## [2.0.103] - 2025-04-12
### Added
- Button to select all options when deleting a torrent, thanks @EugeneKallis
- Add setting to ignore update notifications. A notification will appear regardless of this setting if any GitHub Security Advisories are published in this repo.
### Changed
- Download .zip of torrent files from TorBox when possible, thanks @asylumexp
- Users of AllDebrid and RealDebrid will now have no files downloaded when all files are excluded by filters. Before, if all files were excluded, rdt-client would download all the files in the torrent.
- Reduce number of calls to debrid provider API when no torrents need updating
### Fixed
- The dropdown navigation menu on mobile will now close when you navigate to another page
- Long torrent names without spaces will now wrap across lines
### Security
- Require auth to change debrid api key
## [2.0.102] - 2025-03-07
### Changed
- Fixed Angular build for Docker.
## [2.0.101] - 2025-03-07
### Changed
- Fixed Angular build (again).
## [2.0.100] - 2025-03-07
### Changed
- Fixed Angular build.
## [2.0.99] - 2025-03-07
### Security fix
- The Api/Authentication/Update was not protected by authentication, meaning everyone could reset your password and gain access.
### Added
- Set the useragent for the Bezadd downloader to avoid getting blacklisted by Torbox.
### Changed
- Upgraded to Angular 19.
- Upgraded to Torbox 1.5.
## [2.0.98] - 2025-02-16
### Added
- Added unit tests, thanks @Cucumberrbob!
### Changed
- Fixed symlinks for AllDebrid.
- Upgraded DebridLink.fr to the latest version.
- Fixed nested files in the Premiumize Provider.
- Fixed deleting of torrents in the watch folders.
## [2.0.97] - 2025-02-16
### Added
- Added support for DebridLink.fr.
### Fixed
- Fixed for the internal downloader.
- Added a column for torrent add date.
- Upgraded AllDebrid API.
## [2.0.96] - 2025-01-29
### Added
- Added support for the synology download manager.
- Added a column for torrent add date.
### Changed
- Fixed for the Symlink downloader and AllDebrid.
- Fixed setting the downloader when adding a torrent through the GUI.
## [2.0.95] - 2025-01-19
### Added
- Added the /api/v2/transfer/info qBittorrent endpoint.
### Changed
- AllDebrid Symlink path fixes.
## [2.0.94] - 2025-01-05
### Changed
- AllDebrid path fixes.
## [2.0.93] - 2025-01-03
### Changed
- Torbox fixes.
## [2.0.92] - 2024-12-18
### Changed
- Torbox fixes.
## [2.0.91] - 2024-12-11
### Changed
- Torbox fixes.
## [2.0.90] - 2024-12-06
### Changed
- Download individual files from Torbox instead of a zip file.
@ -286,10 +568,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Fixed the BaseURL content-length setting.
## [2.0.32] - 2023-06-27
### Changed
- Fixed the BaseURL content-length setting.
## [2.0.31] - 2023-06-11
### Added
- Added setting to set the BaseURL.
@ -703,16 +981,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- First release
- Add unraring progress and default auto download / auto remove options.
[Unreleased]: https://github.com/rogerfar/rdt-client/compare/1.5.5...HEAD
[1.5.5]: https://github.com/rogerfar/rdt-client/releases/tag/1.5.5
[1.5.4]: https://github.com/rogerfar/rdt-client/releases/tag/1.5.4
[1.5.3]: https://github.com/rogerfar/rdt-client/releases/tag/1.5.3
[1.5.2]: https://github.com/rogerfar/rdt-client/releases/tag/1.5.2
[1.5.1]: https://github.com/rogerfar/rdt-client/releases/tag/1.5.1
[1.5.0]: https://github.com/rogerfar/rdt-client/releases/tag/1.5
[1.4.0]: https://github.com/rogerfar/rdt-client/releases/tag/1.4
[1.3.0]: https://github.com/rogerfar/rdt-client/releases/tag/1.3
[1.2.0]: https://github.com/rogerfar/rdt-client/releases/tag/1.2
[1.1.0]: https://github.com/rogerfar/rdt-client/releases/tag/1.1
[1.0.0]: https://github.com/rogerfar/rdt-client/releases/tag/v1.0

View file

@ -1,5 +1,5 @@
# Stage 1 - Build the frontend
FROM node:18-alpine3.18 AS node-build-env
FROM node:lts-alpine AS node-build-env
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG BUILDPLATFORM
@ -10,18 +10,18 @@ WORKDIR /appclient
RUN apk add --no-cache git python3 py3-pip make g++
COPY client ./client
COPY root ./root
RUN \
echo "**** Cloning Source Code ****" && \
git clone https://github.com/rogerfar/rdt-client.git . && \
cd client && \
echo "**** Building Code ****" && \
npm ci && \
npm install && \
npx ng build --output-path=out
RUN ls -FCla /appclient/root
# Stage 2 - Build the backend
FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim-amd64 AS dotnet-build-env
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS dotnet-build-env
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG BUILDPLATFORM
@ -30,12 +30,13 @@ ENV BUILDPLATFORM=${BUILDPLATFORM:-linux/amd64}
RUN mkdir /appserver
WORKDIR /appserver
COPY server ./server
RUN \
echo "**** Cloning Source Code ****" && \
git clone https://github.com/rogerfar/rdt-client.git . && \
echo "**** Building Source Code for $TARGETPLATFORM on $BUILDPLATFORM ****" && \
cd server && \
dotnet restore --no-cache RdtClient.sln && dotnet publish --no-restore -c Release -o out ;
dotnet restore --no-cache RdtClient.sln && \
dotnet test && \
dotnet publish --no-restore -c Release -o out ;
# Stage 3 - Build runtime image
FROM ghcr.io/linuxserver/baseimage-alpine:3.20
@ -66,14 +67,14 @@ RUN \
RUN \
if [ "$TARGETPLATFORM" = "linux/arm/v7" ] ; then \
wget https://download.visualstudio.microsoft.com/download/pr/59a041e1-921e-405e-8092-95333f80f9ca/63e83e3feb70e05ca05ed5db3c579be2/aspnetcore-runtime-9.0.0-linux-musl-arm.tar.gz && \
tar zxf aspnetcore-runtime-9.0.0-linux-musl-arm.tar.gz -C /usr/share/dotnet ; \
wget https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.0/aspnetcore-runtime-10.0.0-linux-musl-arm.tar.gz && \
tar zxf aspnetcore-runtime-10.0.0-linux-musl-arm.tar.gz -C /usr/share/dotnet ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \
wget https://download.visualstudio.microsoft.com/download/pr/e137f557-83cb-4f55-b1c8-e5f59ccd3cba/b8ba6f2ab96d0961757b71b00c201f31/aspnetcore-runtime-9.0.0-linux-musl-arm64.tar.gz && \
tar zxf aspnetcore-runtime-9.0.0-linux-musl-arm64.tar.gz -C /usr/share/dotnet ; \
wget https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.0/aspnetcore-runtime-10.0.0-linux-musl-arm64.tar.gz && \
tar zxf aspnetcore-runtime-10.0.0-linux-musl-arm64.tar.gz -C /usr/share/dotnet ; \
else \
wget https://download.visualstudio.microsoft.com/download/pr/86d7a513-fe71-4f37-b9ec-fdcf5566cce8/e72574fc82d7496c73a61f411d967d8e/aspnetcore-runtime-9.0.0-linux-musl-x64.tar.gz && \
tar zxf aspnetcore-runtime-9.0.0-linux-musl-x64.tar.gz -C /usr/share/dotnet ; \
wget https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.0/aspnetcore-runtime-10.0.0-linux-musl-x64.tar.gz && \
tar zxf aspnetcore-runtime-10.0.0-linux-musl-x64.tar.gz -C /usr/share/dotnet ; \
fi
RUN \
@ -89,7 +90,7 @@ ENV PATH "$PATH:/usr/share/dotnet"
# Copy files for app
WORKDIR /app
COPY --from=dotnet-build-env /appserver/server/out .
COPY --from=node-build-env /appclient/client/out ./wwwroot
COPY --from=node-build-env /appclient/client/out/browser ./wwwroot
COPY --from=node-build-env /appclient/root/ /
# ports and volumes

View file

@ -13,7 +13,7 @@ rdt-client is a web a web interface to manage your torrents on Real-Debrid. It s
Our images support multiple architectures such as `x86-64`, `arm64` and `armhf`. We utilise the docker manifest for multi-platform awareness. More information is available from docker [here](https://github.com/docker/distribution/blob/master/docs/spec/manifest-v2-2.md#manifest-list) and our announcement [here](https://blog.linuxserver.io/2019/02/21/the-lsio-pipeline-project/).
Simply pulling `rogerfar/rdt-client` should retrieve the correct image for your arch, but you can also pull specific arch images via tags.
Simply pulling `rogerfar/rdtclient` should retrieve the correct image for your arch, but you can also pull specific arch images via tags.
The architectures supported by this image are:

View file

@ -1,14 +1,15 @@
# Real-Debrid Torrent Client
# Real-Debrid Torrent & Usenet Client
This is a web interface to manage your torrents on Real-Debrid, AllDebrid or Premiumize. 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
- Download all files from Real-Debrid, AllDebrid or Premiumize to your local machine automatically
- 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 15 and .NET 9
- Built with Angular 21 and .NET 10
**You will need a Premium service at Real-Debrid, AllDebrid or Premiumize!**
**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)
@ -16,6 +17,10 @@ This is a web interface to manage your torrents on Real-Debrid, AllDebrid or Pre
[Click here to sign up for Premiumize.](https://www.premiumize.me/)
[Click here to sign up for TorBox.](https://torbox.app/subscription?referral=3d25018e-f30d-4c4b-a714-48c04bc76765)
[Click here to sign up for DebridLink.](https://debrid-link.fr/id/6duif)
<sub>(referal links so I can get a few free premium days)</sub>
## Docker Setup
@ -30,7 +35,7 @@ Instead of running in Docker you can install it as a service in Windows or Linux
## Windows Service
1. Make sure you have the .NET 9 Runtime installed: [https://dotnet.microsoft.com/download/dotnet/9.0](https://dotnet.microsoft.com/download/dotnet/9.0)
1. Make sure you have the **ASP.NET Core Runtime 10.0.0** and the **SDK** installed: [https://dotnet.microsoft.com/download/dotnet/10.0](https://dotnet.microsoft.com/download/dotnet/10.0)
2. Get the latest zip file from the Releases page and extract it to your host.
3. Open the `appsettings.json` file and replace the `LogLevel` `Path` to a path on your host.
4. In `appsettings.json` replace the `Database` `Path` to a path on your host.
@ -52,7 +57,7 @@ Instead of running in Docker you can install it as a service in Linux.
```rm packages-microsoft-prod.deb```
```sudo apt-get update && sudo apt-get install -y dotnet-sdk-9.0```
```sudo apt-get update && sudo apt-get install -y dotnet-sdk-10.0```
2. Get latest archive from [releases](https://github.com/rogerfar/rdt-client/releases):
```wget <zip_url>```
@ -107,16 +112,6 @@ If you use Proxmox for your homelab, you can run rdt-client in a linux container
Currently there 4 available download clients:
#### Internal Downloader
This experimental [downloader](https://github.com/rogerfar/Downloader.NET) can be used to download files with multiple sections in parallel.
It has the following options:
- Download speed (in MB/s): This number indicates the speed in MB/s per download over all parallel downloads and chunks.
- Parallel connections per download: When a file is downloaded it is split in sections, this setting indicates how many sections you will download in parallel.
- Connection Timeout: This number indicates the timeout in milliseconds before a download chunk times out. It will retry each chunk 5 times before completely failing.
#### Bezzad Downloader
This [downloader](https://github.com/bezzad/Downloader) can be used to download files in parallel and with multiple chunks.
@ -153,6 +148,17 @@ Required configuration:
Suggested configuration:
- Automatic retry downloads > 3
#### Synology Download Station
The Synology Download Station downloader uses an external Download Station server. You will need to set this up yourself.
It has the following options:
- Url: The URL to the Synology DownloadStation. A common URL is `http://127.0.0.1:5000`
- Username: The username to use when connecting to the Synology DownloadStation.
- Password: The password to use when connecting to the Synology DownloadStation.
- Download Path: The root path to download the file on the Synology DownloadStation host. If left empty, the default path configured on your Download Station server will be used.
### Troubleshooting
- If you forgot your logins simply delete the `rdtclient.db` and restart the service.
@ -160,7 +166,9 @@ Suggested configuration:
### 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.
@ -177,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.
@ -188,13 +214,24 @@ By default the application runs in the root of your hosted address (i.e. https:/
- NodeJS
- NPM
- Angular CLI
- .NET 9
- Visual Studio 2022
- .NET 10
- 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 --prod`.
1. Open the Visual Studio 2019 project `RdtClient.sln` and `Publish` the `RdtClient.Web` to the given `PublishFolder` target.
1. To debug run `ng serve`, to build run `ng build -c production`.
1. Open the Visual Studio 2025 project `RdtClient.sln` and `Publish` the `RdtClient.Web` to the given `PublishFolder` target.
1. When debugging, make sure to run `RdtClient.Web.dll` and not `IISExpress`.
1. The result is found in `Publish`.
@ -205,3 +242,14 @@ By default the application runs in the root of your hosted address (i.e. https:/
1. To stop: `docker stop rdtclient`
1. To remove: `docker rm rdtclient`
1. Or use `docker-build.bat`
## Misc Install Notes
### Rootless Podman, Linux Host, and CIFS Connections
RDT Client read and write permission tests fail if the CIFS connection is not setup properly, despite permissions working inspection. In the Web GUI, it will report access denied, and in the log file you will see exceptions like this ([dotnet issue](https://github.com/dotnet/runtime/issues/42790)):
```
System.IO.IOException: Permission denied
```
The **nobrl** has to be specified in your CIFS connection - [man page](https://linux.die.net/man/8/mount.cifs).
Example: ```Options=_netdev,credentials=/etc/samba/credentials/600file,rw,uid=SUBUID,gid=SBUGID,nobrl,file_mode=0770,dir_mode=0770,noperm```

14
client/.ncurc.js Normal file
View file

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

View file

@ -37,25 +37,33 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"builder": "@angular/build:application",
"options": {
"outputPath": "../server/RdtClient.Web/wwwroot",
"outputPath": {
"base": "../server/RdtClient.Web/wwwroot",
"browser": ""
},
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"polyfills": ["src/polyfills.ts"],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
"scripts": [],
"browser": "src/main.ts",
"stylePreprocessorOptions": {
"sass": {
"silenceDeprecations": ["if-function"]
}
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
"maximumWarning": "2mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
@ -72,9 +80,7 @@
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
@ -83,24 +89,24 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "angular-proxy.json"
},
"configurations": {
"production": {
"browserTarget": "client:build:production"
"buildTarget": "client:build:production"
},
"development": {
"browserTarget": "client:build:development"
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"builder": "@angular/build:extract-i18n",
"options": {
"browserTarget": "client:build"
"buildTarget": "client:build"
}
},
"lint": {
@ -115,5 +121,31 @@
"cli": {
"analytics": false,
"schematicCollections": ["@angular-eslint/schematics"]
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

28536
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,49 +6,49 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"update": "ng update --force --allow-dirty @angular/cli @angular/core @angular/cdk @angular/flex-layout",
"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": "^16.2.3",
"@angular/cdk": "^16.2.2",
"@angular/common": "^16.2.3",
"@angular/compiler": "^16.2.3",
"@angular/core": "^16.2.3",
"@angular/flex-layout": "^15.0.0-beta.42",
"@angular/forms": "^16.2.3",
"@angular/platform-browser": "^16.2.3",
"@angular/platform-browser-dynamic": "^16.2.3",
"@angular/router": "^16.2.3",
"@fortawesome/fontawesome-free": "^6.4.2",
"@microsoft/signalr": "^6.0.21",
"bulma": "^0.9.4",
"curray": "^1.0.11",
"@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",
"ngx-filesize": "^3.0.2",
"rxjs": "~7.8.1",
"tslib": "^2.6.2",
"zone.js": "~0.13.1"
"filesize": "^11.0.17",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"zone.js": "^0.16.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^16.2.1",
"@angular-eslint/builder": "16.1.1",
"@angular-eslint/eslint-plugin": "16.1.1",
"@angular-eslint/eslint-plugin-template": "16.1.1",
"@angular-eslint/schematics": "16.1.1",
"@angular-eslint/template-parser": "16.1.1",
"@angular/cli": "^16.2.1",
"@angular/compiler-cli": "^16.2.3",
"@angular/language-service": "^16.2.3",
"@types/file-saver": "^2.0.5",
"@types/file-saver-es": "^2.0.1",
"@types/node": "^20.5.8",
"@typescript-eslint/eslint-plugin": "6.5.0",
"@typescript-eslint/parser": "6.5.0",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"typescript": "5.1.6"
"@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.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

@ -1,5 +1,24 @@
<div fxLayout.lt-lg="column" fxLayout.gt-sm="row" fxLayoutGap="20px" class="field">
<div fxFlex>
<div class="field">
<div class="tabs is-toggle is-fullwidth">
<ul>
<li [class.is-active]="type === 'torrent'">
<a (click)="changeType('torrent')">
<span class="icon is-small"><i class="fas fa-magnet" aria-hidden="true"></i></span>
<span>Torrent</span>
</a>
</li>
<li [class.is-active]="type === 'nzb'">
<a (click)="changeType('nzb')">
<span class="icon is-small"><i class="fas fa-file-archive" aria-hidden="true"></i></span>
<span>NZB</span>
</a>
</li>
</ul>
</div>
</div>
<div class="field">
<div [hidden]="type !== 'torrent'">
<div class="field">
<label class="label">Torrent file</label>
<div class="file has-name">
@ -31,30 +50,66 @@
></textarea>
</div>
</div>
</div>
<div [hidden]="type !== 'nzb'">
<div class="field">
<div class="control">
<button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
<span>Add Torrent</span>
</button>
<label class="label">NZB file</label>
<div class="file has-name">
<label class="file-label">
<input class="file-input" type="file" name="resume" (change)="pickFile($event)" [disabled]="saving" />
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label"> Pick an NZB file... </span>
</span>
<span class="file-name">
{{ fileName }}
</span>
</label>
</div>
</div>
<div class="field">
<label class="label">NZB Link</label>
<div class="control">
<textarea
class="textarea"
placeholder="Paste your NZB link here"
[(ngModel)]="nzbLink"
[disabled]="saving"
></textarea>
</div>
</div>
</div>
<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") {
<span>Add Torrent</span>
} @else {
<span>Add NZB</span>
}
</button>
</div>
</div>
<div>
<div class="separator">Advanced settings</div>
<div class="field">
<label class="label">Downloader</label>
<div class="control select is-fullwidth">
<select [(ngModel)]="downloadClient" (ngModelChange)="setFinishAction()">
<option [ngValue]="0">Internal Downloader</option>
<option [ngValue]="1">Bezzad</option>
<option [ngValue]="2">Aria2c</option>
<option [ngValue]="3">Symlink Downloader</option>
<option [ngValue]="0">Bezzad</option>
<option [ngValue]="1">Aria2c</option>
<option [ngValue]="2">Symlink Downloader</option>
<option [ngValue]="3">Synology DownloadStation</option>
</select>
</div>
<p class="help">
Select which downloader is used to download this torrent 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">
@ -66,8 +121,8 @@
</select>
</div>
<p class="help">
When a torrent is finished downloading 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>
@ -83,11 +138,15 @@
</div>
</div>
</div>
<p class="help" *ngIf="provider === 'AllDebrid'">
When downloading with AllDebrid it cannot be guaranteed that only files above this limit will be download as
some files are grouped together in 1 large archive.
</p>
<p class="help" *ngIf="downloadAction === 2">This setting does not apply to manually selected files.</p>
@if (provider === "AllDebrid") {
<p class="help">
When downloading with AllDebrid it cannot be guaranteed that only files above this limit will be download as
some files are grouped together in 1 large archive.
</p>
}
@if (downloadAction === 2) {
<p class="help">This setting does not apply to manually selected files.</p>
}
</div>
<div class="field">
@ -103,8 +162,12 @@
Select only the files that are matching this regular expression. Only use this setting OR the Exclude files
setting, not both.
</p>
<p class="help" *ngIf="downloadAction === 2">This setting does not apply to manually selected files.</p>
<p class="help is-danger" *ngIf="includeRegexError">{{ includeRegexError }}</p>
@if (downloadAction === 2) {
<p class="help">This setting does not apply to manually selected files.</p>
}
@if (includeRegexError) {
<p class="help is-danger">{{ includeRegexError }}</p>
}
</div>
<div class="field">
@ -120,8 +183,12 @@
Ignore files that are matching this regular expression. Only use this setting OR the Include files setting, not
both.
</p>
<p class="help" *ngIf="downloadAction === 2">This setting does not apply to manually selected files.</p>
<p class="help is-danger" *ngIf="excludeRegexError">{{ excludeRegexError }}</p>
@if (downloadAction === 2) {
<p class="help">This setting does not apply to manually selected files.</p>
}
@if (excludeRegexError) {
<p class="help is-danger">{{ excludeRegexError }}</p>
}
</div>
<div class="field">
@ -129,17 +196,46 @@
<div class="control select is-fullwidth">
<select [(ngModel)]="finishedAction">
<option [ngValue]="0">Do nothing</option>
<option [ngValue]="1" *ngIf="downloadClient !== 2">Remove torrent from provider and client</option>
<option [ngValue]="2" *ngIf="downloadClient !== 2">Remove torrent from provider</option>
<option [ngValue]="3">Remove torrent from client</option>
@if (downloadClient !== 2) {
<option [ngValue]="1">Remove from provider and client</option>
<option [ngValue]="2">Remove from provider</option>
}
<option [ngValue]="3">Remove from client</option>
</select>
</div>
</div>
<div class="field">
<label class="label">Finished Action Delay (minutes)</label>
<div class="control is-fullwidth">
<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.
</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>
@ -149,10 +245,13 @@
<div class="control">
<input class="input" type="number" step="1" [(ngModel)]="priority" />
</div>
<p class="help">
Set the priority for this torrent 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>
<div class="field">
<div>
<div class="field">
<label class="label">Automatic retry downloads</label>
<div class="control">
@ -161,13 +260,13 @@
<p class="help">When a single download fails it will retry it this many times before marking it as failed.</p>
</div>
<div class="field">
<label class="label">Automatic retry torrent</label>
<label class="label">Automatic retry download</label>
<div class="control">
<input class="input" type="number" max="1000" min="0" step="1" [(ngModel)]="torrentRetryAttempts" />
</div>
<p class="help">
When a single download has failed multiple times (see setting above) or when the torrent itself received an
error it will retry the full torrent this many times before marking it failed.
When a single download has failed multiple times (see setting above) or when the download itself received an
error it will retry the full download this many times before marking it failed.
</p>
</div>
<div class="field">
@ -181,13 +280,13 @@
</p>
</div>
<div class="field">
<label class="label">Torrent maximum lifetime</label>
<label class="label">Maximum lifetime</label>
<div class="control">
<input class="input" type="number" max="100000" min="0" step="1" [(ngModel)]="torrentLifetime" />
</div>
<p class="help">
The maximum lifetime of a torrent in minutes. When this time has passed, mark the torrent as error. If the
torrent is completed and has downloads, 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>
@ -195,14 +294,8 @@
<div class="field">
<div class="control">
<div class="notification is-danger is-light" *ngIf="error">{{ error }}</div>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
<span>Add Torrent</span>
</button>
@if (error) {
<div class="notification is-danger is-light">{{ error }}</div>
}
</div>
</div>

View file

@ -3,21 +3,6 @@
width: 24em;
}
.scroll-container {
width: 100%;
overflow-x: auto;
overflow-y: auto;
max-height: 603px;
}
.is-fullwidth-label {
width: 100%;
}
.is-block {
display: block;
}
.separator {
display: flex;
align-items: center;
@ -43,6 +28,28 @@
margin-left: 0.25em;
}
.strike-through {
text-decoration: line-through;
.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,26 +1,44 @@
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 { Torrent, TorrentFileAvailability } from '../models/torrent.model';
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',
templateUrl: './add-new-torrent.component.html',
styleUrls: ['./add-new-torrent.component.scss'],
imports: [FormsModule, NgClass],
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;
public nzbLink: string;
private currentTorrentFile: string;
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;
public finishedActionDelay: number = 0;
public downloadMinSize: number = 0;
public includeRegex: string = '';
public excludeRegex: string = '';
@ -41,38 +59,93 @@ 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,
) {}
public get category(): string {
return this._category;
}
public set category(value: string) {
this._category = value;
this.updateFilteredCategories();
}
ngOnInit(): void {
this.settingsService.get().subscribe((settings) => {
const providerSetting = settings.first((m) => m.key === 'Provider:Provider');
this.provider = providerSetting.enumValues[providerSetting.value as number];
this.downloadClient = settings.first((m) => m.key === 'DownloadClient:Client')?.value as number;
this.activatedRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
if (params['type'] === 'nzb') {
this.type = 'nzb';
} else if (params['type'] === 'torrent') {
this.type = 'torrent';
}
this.category = settings.first((m) => m.key === 'Gui:Default:Category')?.value as string;
this.hostDownloadAction = this.downloadAction = settings.first((m) => m.key === 'Gui:Default:HostDownloadAction')
?.value as number;
this.downloadAction =
settings.first((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.finishedAction = settings.first((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.downloadMinSize = settings.first((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
this.includeRegex = settings.first((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
this.excludeRegex = settings.first((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
this.torrentRetryAttempts = settings.first((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
this.downloadRetryAttempts = settings.first((m) => m.key === 'Gui:Default:DownloadRetryAttempts')
?.value as number;
this.torrentDeleteOnError = settings.first((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
this.torrentLifetime = settings.first((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
this.priority = settings.first((m) => m.key === 'Gui:Default:Priority')?.value as number;
this.setFinishAction();
if (params['magnet']) {
this.magnetLink = decodeURIComponent(params['magnet']);
this.type = 'torrent';
}
if (params['nzb']) {
this.nzbLink = decodeURIComponent(params['nzb']);
this.type = 'nzb';
}
});
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.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() {
@ -85,10 +158,16 @@ export class AddNewTorrentComponent implements OnInit {
}
}
public changeType(type: 'torrent' | 'nzb'): void {
this.type = type;
this.fileName = null;
this.selectedFile = null;
}
public pickFile(evt: Event): void {
const files = (evt.target as HTMLInputElement).files;
if (files.length === 0) {
if (files == null || files.length === 0) {
return;
}
@ -101,25 +180,6 @@ export class AddNewTorrentComponent implements OnInit {
this.checkFiles();
}
public downloadFileChecked(file: string): void {
this.downloadFiles[file] = !this.downloadFiles[file];
this.allSelected = true;
this.availableFiles.forEach((file) => {
if (!this.downloadFiles[file.filename]) {
this.allSelected = false;
}
});
}
public downloadFileCheckedAll(): void {
this.allSelected = !this.allSelected;
this.availableFiles.forEach((file) => {
this.downloadFiles[file.filename] = this.allSelected;
});
}
public ok(): void {
this.saving = true;
this.error = null;
@ -128,7 +188,7 @@ export class AddNewTorrentComponent implements OnInit {
if (this.downloadAction === 2) {
const selectedFiles = [];
for (let filePath in this.downloadFiles) {
for (const filePath in this.downloadFiles) {
if (this.downloadFiles[filePath] === true) {
selectedFiles.push(filePath);
}
@ -147,6 +207,7 @@ export class AddNewTorrentComponent implements OnInit {
torrent.hostDownloadAction = this.hostDownloadAction;
torrent.downloadAction = this.downloadAction;
torrent.finishedAction = this.finishedAction;
torrent.finishedActionDelay = this.finishedActionDelay;
torrent.downloadMinSize = this.downloadMinSize;
torrent.includeRegex = this.includeRegex;
torrent.excludeRegex = this.excludeRegex;
@ -158,29 +219,49 @@ export class AddNewTorrentComponent implements OnInit {
torrent.lifetime = this.torrentLifetime;
torrent.downloadClient = this.downloadClient;
if (this.magnetLink) {
this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe(
() => {
this.router.navigate(['/']);
},
(err) => {
this.error = err.error;
this.saving = false;
},
);
} else if (this.selectedFile) {
this.torrentService.uploadFile(this.selectedFile, torrent).subscribe(
() => {
this.router.navigate(['/']);
},
(err) => {
this.error = err.error;
this.saving = false;
},
);
if (this.type === 'torrent') {
torrent.type = DownloadType.Torrent;
if (this.magnetLink) {
this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe({
next: () => this.router.navigate(['/']),
error: (err) => {
this.error = err.error;
this.saving = false;
},
});
} else if (this.selectedFile) {
this.torrentService.uploadFile(this.selectedFile, torrent).subscribe({
next: () => this.router.navigate(['/']),
error: (err) => {
this.error = err.error;
this.saving = false;
},
});
} else {
this.error = 'No magnet or file uploaded';
this.saving = false;
}
} else {
this.error = 'No magnet or file uploaded';
this.saving = false;
if (this.nzbLink) {
this.torrentService.uploadNzbLink(this.nzbLink, torrent).subscribe({
next: () => this.router.navigate(['/']),
error: (err) => {
this.error = err.error;
this.saving = false;
},
});
} else if (this.selectedFile) {
this.torrentService.uploadNzbFile(this.selectedFile, torrent).subscribe({
next: () => this.router.navigate(['/']),
error: (err) => {
this.error = err.error;
this.saving = false;
},
});
} else {
this.error = 'No NZB link or file uploaded';
this.saving = false;
}
}
}
@ -191,6 +272,9 @@ export class AddNewTorrentComponent implements OnInit {
}
public checkFiles(): void {
if (this.type === 'nzb') {
return;
}
if (this.magnetLink && this.magnetLink === this.currentTorrentFile) {
return;
}
@ -202,8 +286,8 @@ export class AddNewTorrentComponent implements OnInit {
this.allSelected = true;
if (this.magnetLink) {
this.torrentService.checkFilesMagnet(this.magnetLink).subscribe(
(result) => {
this.torrentService.checkFilesMagnet(this.magnetLink).subscribe({
next: (result) => {
this.saving = false;
this.availableFiles = result;
this.currentTorrentFile = this.magnetLink;
@ -211,42 +295,30 @@ export class AddNewTorrentComponent implements OnInit {
this.downloadFiles[file.filename] = true;
});
},
(err) => {
error: (err) => {
this.error = err.error;
this.saving = false;
},
);
});
} else if (this.selectedFile) {
this.torrentService.checkFiles(this.selectedFile).subscribe(
(result) => {
this.torrentService.checkFiles(this.selectedFile).subscribe({
next: (result) => {
this.saving = false;
this.availableFiles = result;
result.forEach((file) => {
this.downloadFiles[file.filename] = true;
});
},
(err) => {
error: (err) => {
this.error = err.error;
this.saving = false;
},
);
});
} else {
this.saving = false;
}
}
public isRegexExcluded(file: TorrentFileAvailability): boolean {
if (this.regexSelected == null) {
return false;
}
if (this.regexSelected.find((m) => m.filename === file.filename) == null) {
return true;
}
return false;
}
public verifyRegex(): void {
this.includeRegexError = null;
this.excludeRegexError = null;

View file

@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AddNewTorrentComponent } from './add-new-torrent/add-new-torrent.component';
import { AuthResolverService } from './auth-resolver.service';
import { authResolver } from './auth-resolver.service';
import { LoginComponent } from './login/login.component';
import { MainLayoutComponent } from './main-layout/main-layout.component';
import { ProfileComponent } from './profile/profile.component';
@ -23,7 +23,7 @@ const routes: Routes = [
path: '',
component: MainLayoutComponent,
resolve: {
isLoggedIn: AuthResolverService,
isLoggedIn: authResolver,
},
children: [
{

View file

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

View file

@ -1,64 +0,0 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { APP_BASE_HREF } from '@angular/common';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { curray } from 'curray';
import { FileSizePipe, NgxFilesizeModule } from 'ngx-filesize';
import { AddNewTorrentComponent } from './add-new-torrent/add-new-torrent.component';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { AuthInterceptor } from './auth.interceptor';
import { DecodeURIPipe } from './decode-uri.pipe';
import { DownloadStatusPipe } from './download-status.pipe';
import { LoginComponent } from './login/login.component';
import { MainLayoutComponent } from './main-layout/main-layout.component';
import { NavbarComponent } from './navbar/navbar.component';
import { Nl2BrPipe } from './nl2br.pipe';
import { ProfileComponent } from './profile/profile.component';
import { SettingsComponent } from './settings/settings.component';
import { SetupComponent } from './setup/setup.component';
import { TorrentStatusPipe } from './torrent-status.pipe';
import { TorrentTableComponent } from './torrent-table/torrent-table.component';
import { TorrentComponent } from './torrent/torrent.component';
import { SortPipe } from './sort.pipe';
curray();
@NgModule({
declarations: [
AppComponent,
MainLayoutComponent,
NavbarComponent,
AddNewTorrentComponent,
TorrentTableComponent,
SettingsComponent,
TorrentStatusPipe,
DownloadStatusPipe,
LoginComponent,
SetupComponent,
TorrentComponent,
DecodeURIPipe,
ProfileComponent,
Nl2BrPipe,
SortPipe,
],
imports: [
BrowserModule,
AppRoutingModule,
FormsModule,
HttpClientModule,
NgxFilesizeModule,
FlexLayoutModule,
ClipboardModule,
],
providers: [
FileSizePipe,
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: APP_BASE_HREF, useValue: (window as any)['_app_base'] || '/' },
],
bootstrap: [AppComponent],
})
export class AppModule {}

View file

@ -1,15 +1,7 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { ResolveFn } from '@angular/router';
@Injectable({
providedIn: 'root',
})
export class AuthResolverService {
constructor(private authService: AuthService) {}
resolve() {
return this.authService.isLoggedIn();
}
}
export const authResolver: ResolveFn<boolean> = () => {
return inject(AuthService).isLoggedIn();
};

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(
@ -16,8 +16,8 @@ export class AuthInterceptor implements HttpInterceptor {
} else if (error && (error.status === 401 || error.status === 403)) {
this.router.navigate(['/login']);
}
return throwError(error);
})
return throwError(() => error);
}),
);
}
}

View file

@ -1,13 +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,8 +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,12 +1,10 @@
import { Pipe, PipeTransform } from '@angular/core';
import { FileSizePipe } from 'ngx-filesize';
import { Pipe, PipeTransform, inject } from '@angular/core';
import { Download } from './models/download.model';
import { FileSizePipe } from './filesize.pipe';
@Pipe({
name: 'downloadStatus',
})
@Pipe({ name: 'downloadStatus' })
export class DownloadStatusPipe implements PipeTransform {
constructor(private pipe: FileSizePipe) {}
private pipe = inject(FileSizePipe);
transform(value: Download): string {
if (!value) {
@ -26,11 +24,7 @@ export class DownloadStatusPipe implements PipeTransform {
}
if (value.unpackingStarted) {
let progress = (value.bytesDone / value.bytesTotal) * 100;
if (isNaN(progress)) {
progress = 0;
}
const progress = (value.bytesDone / value.bytesTotal || 0) * 100;
return `Unpacking ${progress.toFixed(2)}%`;
}
@ -44,11 +38,7 @@ export class DownloadStatusPipe implements PipeTransform {
}
if (value.downloadStarted) {
let progress = (value.bytesDone / value.bytesTotal) * 100;
if (isNaN(progress)) {
progress = 0;
}
const progress = (value.bytesDone / value.bytesTotal || 0) * 100;
const speed = this.pipe.transform(value.speed, 'filesize');

View file

@ -0,0 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core';
import { filesize } from 'filesize';
@Pipe({ name: 'filesize' })
export class FileSizePipe implements PipeTransform {
private static transformOne(value: number, options?: any): string {
return filesize(value, options).toString();
}
transform(value: number | number[], options?: any) {
if (Array.isArray(value)) {
return value.map((val) => FileSizePipe.transformOne(val, options));
}
return FileSizePipe.transformOne(value, options);
}
}

View file

@ -7,13 +7,14 @@
<div class="box">
<form (ngSubmit)="login()">
<div class="field">
<label for="" class="label">Username</label>
<label for="userName" class="label">Username</label>
<div class="control has-icons-left">
<input
type="text"
placeholder=""
class="input"
name="userName"
id="userName"
(change)="setUserName($event)"
required
/>
@ -23,9 +24,16 @@
</div>
</div>
<div class="field">
<label for="" class="label" name="password">Password</label>
<label for="password" class="label">Password</label>
<div class="control has-icons-left">
<input type="password" class="input" required name="password" (change)="setPassword($event)" />
<input
type="password"
class="input"
required
name="password"
id="password"
(change)="setPassword($event)"
/>
<span class="icon is-small is-left">
<i class="fa fa-lock"></i>
</span>
@ -41,9 +49,11 @@
Login
</button>
</div>
<div class="notification is-danger is-light" *ngIf="error?.length > 0">
{{ error }}
</div>
@if (error?.length > 0) {
<div class="notification is-danger is-light">
{{ error }}
</div>
}
</form>
</div>
</div>

View file

@ -1,20 +1,25 @@
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';
import { NgClass } from '@angular/common';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss'],
imports: [FormsModule, NgClass],
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;
}
@ -26,14 +31,12 @@ export class LoginComponent {
public login(): void {
this.error = null;
this.loggingIn = true;
this.authService.login(this.userName, this.password).subscribe(
() => {
this.router.navigate(['/']);
},
(err) => {
this.authService.login(this.userName, this.password).subscribe({
next: () => this.router.navigate(['/']),
error: (err) => {
this.loggingIn = false;
this.error = err.error;
}
);
},
});
}
}

View file

@ -1,9 +1,13 @@
import { Component } from '@angular/core';
import { NavbarComponent } from '../navbar/navbar.component';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-main-layout',
templateUrl: './main-layout.component.html',
styleUrls: ['./main-layout.component.scss'],
imports: [NavbarComponent, RouterOutlet],
standalone: true,
})
export class MainLayoutComponent {
constructor() {}

View file

@ -0,0 +1,6 @@
export interface DiskSpaceStatus {
isPaused: boolean;
availableSpaceGB: number;
thresholdGB: number;
lastCheckTime: Date;
}

View file

@ -4,4 +4,6 @@ export class Profile {
public expiration: Date;
public currentVersion: string;
public latestVersion: string;
public isInsecure: boolean;
public disableUpdateNotification: boolean;
}

View file

@ -0,0 +1,4 @@
export interface RateLimitStatus {
nextDequeueTime: Date | null;
secondsRemaining: number;
}

View file

@ -8,6 +8,7 @@ export class Torrent {
public hostDownloadAction: number;
public downloadAction: number;
public finishedAction: number;
public finishedActionDelay: number;
public downloadMinSize: number;
public includeRegex: string;
public excludeRegex: string;
@ -27,6 +28,7 @@ export class Torrent {
public lifetime: number;
public priority: number;
public type: DownloadType;
public error: string;
public rdId: string;
@ -42,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 {
@ -62,11 +67,18 @@ export class TorrentFileAvailability {
}
export enum RealDebridStatus {
Processing = 0,
WaitingForFileSelection = 1,
Downloading = 2,
Finished = 3,
Uploading = 4,
Queued = 0,
Processing = 1,
WaitingForFileSelection = 2,
Downloading = 3,
Finished = 4,
Uploading = 5,
Error = 99,
}
export enum DownloadType {
Torrent = 0,
Nzb = 1,
}

View file

@ -0,0 +1,3 @@
export class Version {
public version: string;
}

View file

@ -24,13 +24,13 @@
<span class="icon">
<i class="fas fa-table" aria-hidden="true"></i>
</span>
<span>Torrents</span>
<span>Torrents/NZBs</span>
</a>
<a class="navbar-item" routerLink="/add">
<a class="navbar-item" routerLink="/add" [queryParams]="{ type: 'torrent' }">
<span class="icon">
<i class="fas fa-plus" aria-hidden="true"></i>
</span>
<span>Add New Torrent</span>
<span>Add Torrent/NZB</span>
</a>
<a class="navbar-item" routerLink="/settings">
<span class="icon">
@ -38,13 +38,18 @@
</span>
<span>Settings</span>
</a>
<a class="navbar-item" *ngIf="profile" href="{{ providerLink }}" target="_blank" rel="noopener">
<span class="icon">
<i class="fas fa-euro-sign" aria-hidden="true"></i>
</span>
<span *ngIf="profile.expiration">Premium Status: {{ profile.expiration | date }}</span>
<span *ngIf="!profile.expiration" class="no-premium">Not premium</span>
</a>
@if (profile) {
<a class="navbar-item" href="{{ providerLink }}" target="_blank" rel="noopener">
<span class="icon">
<i class="fas fa-euro-sign" aria-hidden="true"></i>
</span>
@if (profile.expiration) {
<span>Premium Status: {{ profile.expiration | date }}</span>
} @else {
<span class="no-premium">Not premium</span>
}
</a>
}
</div>
<div class="navbar-end">
@ -55,16 +60,26 @@
<a class="navbar-item" routerLink="profile"> Profile </a>
<a class="navbar-item" (click)="logout()"> Logout </a>
<hr class="navbar-divider" />
<a href="https://github.com/rogerfar/rdt-client" target="_blank" class="navbar-item">Version 2.0.90</a>
<a href="https://github.com/rogerfar/rdt-client" target="_blank" class="navbar-item">Version {{ version }}</a>
</div>
</div>
</div>
</div>
</nav>
<div
class="notification is-warning"
*ngIf="profile && profile.latestVersion && profile.currentVersion !== profile.latestVersion"
>
Version {{ profile.latestVersion }} of RealDebrid Client was found. You are currently on version
{{ profile.currentVersion }}.
</div>
@if (
profile &&
(!profile.disableUpdateNotification || profile.isInsecure) &&
profile.latestVersion &&
profile.currentVersion !== profile.latestVersion
) {
<div class="notification" [ngClass]="profile.isInsecure ? 'is-danger' : 'is-warning'">
@if (profile.isInsecure) {
<span> Your current version is insecure. </span>
}
<span>
Version {{ profile.latestVersion }} of RealDebrid Client was found. You are currently on version
{{ profile.currentVersion }}.
</span>
</div>
}

View file

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

View file

@ -1,53 +1,77 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
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',
templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss'],
imports: [RouterLink, NgClass, DatePipe],
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,
) {}
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;
}
});
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()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
this.version = result.version;
});
}
public logout(): void {
this.authService.logout().subscribe(
() => {
this.router.navigate(['/login']);
},
(err) => {},
);
this.authService.logout().subscribe({ next: () => this.router.navigate(['/login']), error: console.error });
}
}

View file

@ -1,11 +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

@ -19,13 +19,17 @@
<div class="field">
<div class="control">
<div class="notification is-danger is-light" *ngIf="error !== null">Error saving: {{ error }}</div>
@if (error !== null) {
<div class="notification is-danger is-light">Error saving: {{ error }}</div>
}
</div>
</div>
<div class="field">
<div class="control">
<div class="notification is-success is-light" *ngIf="success">Your profile has been updated</div>
@if (success) {
<div class="notification is-success is-light">Your profile has been updated</div>
}
</div>
</div>

View file

@ -1,13 +1,17 @@
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';
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
styleUrls: ['./profile.component.scss'],
imports: [FormsModule, NgClass],
standalone: true,
})
export class ProfileComponent {
constructor(private authService: AuthService) {}
private authService = inject(AuthService);
public username: string;
public password: string;
@ -21,16 +25,16 @@ export class ProfileComponent {
this.error = null;
this.saving = true;
this.authService.update(this.username, this.password).subscribe(
() => {
this.authService.update(this.username, this.password).subscribe({
next: () => {
this.success = true;
this.saving = false;
},
(err) => {
error: (err) => {
this.error = err.error;
this.success = false;
this.saving = false;
}
);
},
});
}
}

View file

@ -1,15 +1,17 @@
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';
import { APP_BASE_HREF } from '@angular/common';
import { Version } from './models/version.model';
@Injectable({
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`);
@ -23,6 +25,10 @@ export class SettingsService {
return this.http.get<Profile>(`${this.baseHref}Api/Settings/Profile`);
}
public getVersion(): Observable<Version> {
return this.http.get<Version>(`${this.baseHref}Api/Settings/Version`);
}
public testPath(path: string): Observable<void> {
return this.http.post<void>(`${this.baseHref}Api/Settings/TestPath`, { path });
}

View file

@ -1,150 +1,219 @@
<div class="tabs">
<ul>
<li [ngClass]="{ 'is-active': activeTab === i }" (click)="activeTab = i" *ngFor="let tab of tabs; let i = index">
<a>{{ tab.displayName }}</a>
</li>
@for (tab of tabs; track tab.key; let i = $index) {
<li [ngClass]="{ 'is-active': activeTab === i }" (click)="activeTab = i">
<a>{{ tab.displayName }}</a>
</li>
}
<li [ngClass]="{ 'is-active': activeTab === 99 }" (click)="activeTab = 99">
<a>Speed Tests</a>
</li>
</ul>
</div>
<div *ngFor="let tab of tabs; let i = index" [hidden]="activeTab !== i">
<h3>{{ tab.description }}</h3>
<ng-container *ngFor="let setting of tab.settings">
<h3 *ngIf="setting.type === 'Object'" class="title is-3" style="margin-top: 1.2rem">{{ setting.displayName }}</h3>
@for (tab of tabs; track tab.key; let i = $index) {
<div [hidden]="activeTab !== i">
<h3>{{ tab.description }}</h3>
@for (setting of tab.settings; track setting.key) {
@if (setting.type === "Object") {
<h3 class="title is-3" style="margin-top: 1.2rem">{{ setting.displayName }}</h3>
}
<div class="field">
@if (setting.type !== "Boolean" && setting.type !== "Object") {
<label class="label">{{ setting.displayName }}</label>
}
@switch (setting.type) {
@case ("String") {
<div class="control">
<input class="input" type="text" [(ngModel)]="setting.value" />
</div>
}
@case ("Int32") {
<div class="control">
<input class="input" type="number" [(ngModel)]="setting.value" />
</div>
}
@case ("Boolean") {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="setting.value" />
{{ setting.displayName }}
</label>
}
@case ("Enum") {
<div class="control select is-fullwidth">
<select [(ngModel)]="setting.value">
@for (kvp of setting.enumValues | keyvalue; track kvp) {
<option [value]="kvp.key">{{ kvp.value }}</option>
}
</select>
</div>
}
@case ("Object") {}
@default {
<div class="control">Invalid setting type {{ setting.type }}</div>
}
}
<p class="help" [innerHtml]="setting.description | nl2br"></p>
@if (setting.key === "DownloadClient:Aria2cSecret") {
<button
class="button is-warning"
(click)="testAria2cConnection()"
[disabled]="saving"
[ngClass]="{ 'is-loading': saving }"
>
Test aria2 connection
</button>
@if (testAria2cConnectionError) {
<div class="notification is-danger is-light" style="margin-top: 1rem">
Could connect to Aria2 client<br />
{{ testAria2cConnectionError }}
</div>
}
@if (testAria2cConnectionSuccess) {
<div class="notification is-success is-light" style="margin-top: 1rem">
Found Aria2 client version {{ testAria2cConnectionSuccess }}
</div>
}
}
</div>
}
</div>
}
@if (activeTab === 99) {
<div>
<div class="field">
<label class="label" *ngIf="setting.type !== 'Boolean' && setting.type !== 'Object'">{{
setting.displayName
}}</label>
<ng-container [ngSwitch]="setting.type">
<div class="control" *ngSwitchCase="'String'">
<input class="input" type="text" [(ngModel)]="setting.value" />
</div>
<div class="control" *ngSwitchCase="'Int32'">
<input class="input" type="number" [(ngModel)]="setting.value" />
</div>
<label class="checkbox" *ngSwitchCase="'Boolean'">
<input type="checkbox" [(ngModel)]="setting.value" />
{{ setting.displayName }}
</label>
<div class="control select is-fullwidth" *ngSwitchCase="'Enum'">
<select [(ngModel)]="setting.value">
<option [value]="kvp.key" *ngFor="let kvp of setting.enumValues | keyvalue">{{ kvp.value }}</option>
</select>
</div>
<ng-container *ngSwitchCase="'Object'"></ng-container>
<div class="control" *ngSwitchDefault>Invalid setting type {{ setting.type }}</div>
</ng-container>
<p class="help" [innerHtml]="setting.description | nl2br"></p>
<ng-container *ngIf="setting.key === 'DownloadClient:Aria2cSecret'">
<button
class="button is-warning"
(click)="testAria2cConnection()"
[disabled]="saving"
[ngClass]="{ 'is-loading': saving }"
>
Test aria2 connection
</button>
<div class="notification is-danger is-light" style="margin-top: 1rem" *ngIf="testAria2cConnectionError">
Could connect to Aria2 client<br />
{{ testAria2cConnectionError }}
</div>
<div class="notification is-success is-light" style="margin-top: 1rem" *ngIf="testAria2cConnectionSuccess">
Found Aria2 client version {{ testAria2cConnectionSuccess }}
</div>
</ng-container>
</div>
</ng-container>
</div>
<div *ngIf="activeTab === 99">
<div class="field">
<label class="label">Test download path permissions</label>
<div class="control">
<button
class="button is-warning"
(click)="testDownloadPath()"
[disabled]="saving"
[ngClass]="{ 'is-loading': saving }"
*ngIf="!testPathError && !testPathSuccess"
>
Test permissions
</button>
<div class="notification is-danger is-light" *ngIf="testPathError">
Could not test your download path<br />
{{ testPathError }}
<label class="label">Test download path permissions</label>
<div class="control">
@if (!testPathError && !testPathSuccess) {
<button
class="button is-warning"
(click)="testDownloadPath()"
[disabled]="saving"
[ngClass]="{ 'is-loading': saving }"
>
Test permissions
</button>
}
@if (testPathError) {
<div class="notification is-danger is-light">
Could not test your download path<br />
{{ testPathError }}
</div>
}
@if (testPathSuccess) {
<div class="notification is-success is-light">Your download path looks good!</div>
}
</div>
<div class="notification is-success is-light" *ngIf="testPathSuccess">Your download path looks good!</div>
<div class="help">This will check if the download folder has write permissions.</div>
</div>
<div class="help">This will check if the download folder has write permissions.</div>
</div>
<div class="field">
<label class="label">Test Real-Debrid download speed</label>
<div class="control">
<button
class="button is-warning"
(click)="testDownloadSpeed()"
[disabled]="saving"
[ngClass]="{ 'is-loading': saving }"
*ngIf="!testDownloadSpeedError && !testDownloadSpeedSuccess"
>
Test download speed
</button>
<div class="notification is-danger is-light" *ngIf="testDownloadSpeedError">
Could not test your download speed<br />
{{ testDownloadSpeedError }}
<div class="field">
<label class="label">Test Real-Debrid download speed</label>
<div class="control">
@if (testDownloadSpeedError) {
<div class="notification is-danger is-light">
Could not test your download speed<br />
{{ testDownloadSpeedError }}
</div>
} @else if (testDownloadSpeedSuccess) {
<div class="notification is-success is-light">Download speed {{ testDownloadSpeedSuccess | filesize }}/s</div>
} @else {
<button
class="button is-warning"
(click)="testDownloadSpeed()"
[disabled]="saving"
[ngClass]="{ 'is-loading': saving }"
>
Test download speed
</button>
}
</div>
<div class="notification is-success is-light" *ngIf="testDownloadSpeedSuccess">
Download speed {{ testDownloadSpeedSuccess | filesize }}/s
<div class="help">
This will attempt to download a 10GB file from Real-Debrid. When 50MB has been downloaded the test will stop.
</div>
</div>
<div class="help">
This will attempt to download a 10GB file from Real-Debrid. When 50MB has been downloaded the test will stop.
<div class="field">
<label class="label">Test download folder write speed</label>
<div class="control">
@if (testWriteSpeedError) {
<div class="notification is-danger is-light">
Could not test your download speed<br />
{{ testWriteSpeedError }}
</div>
} @else if (testWriteSpeedSuccess) {
<div class="notification is-success is-light">Write speed {{ testWriteSpeedSuccess | filesize }}/s</div>
} @else {
<button
class="button is-warning"
(click)="testWriteSpeed()"
[disabled]="saving"
[ngClass]="{ 'is-loading': saving }"
>
Test write speed
</button>
}
</div>
<div class="help">This will write a small file to your download folder to see how fast it can write to it.</div>
</div>
</div>
<div class="field">
<label class="label">Test download folder write speed</label>
<div class="control">
<button
class="button is-warning"
(click)="testWriteSpeed()"
[disabled]="saving"
[ngClass]="{ 'is-loading': saving }"
*ngIf="!testWriteSpeedError && !testWriteSpeedSuccess"
>
Test write speed
</button>
<div class="notification is-danger is-light" *ngIf="testWriteSpeedError">
Could not test your download speed<br />
{{ testWriteSpeedError }}
</div>
<div class="notification is-success is-light" *ngIf="testWriteSpeedSuccess">
Write speed {{ testWriteSpeedSuccess | filesize }}/s
</div>
</div>
<div class="help">This will write a small file to your download folder to see how fast it can write to it.</div>
</div>
</div>
}
<div class="field">
<div class="control">
<div class="notification is-danger is-light" *ngIf="error?.length > 0">Error saving settings: {{ error }}</div>
@if (error?.length > 0) {
<div class="notification is-danger is-light">Error saving settings: {{ error }}</div>
}
</div>
</div>
<div class="field" *ngIf="activeTab < 99">
<div class="control">
<button class="button is-success" (click)="ok()" [disabled]="saving" [ngClass]="{ 'is-loading': saving }">
Save Settings
</button>
@if (activeTab === 0) {
<div class="field">
<label class="label">Register client as magnet link handler</label>
<div class="control">
<button
class="button is-info"
type="button"
(click)="registerMagnetHandler()"
[disabled]="!canRegisterMagnetHandler"
>
<span>
{{ canRegisterMagnetHandler ? "Register Handler" : "Unavailable in This Browser" }}
</span>
</button>
</div>
<p class="help">
@if (canRegisterMagnetHandler) {
This will attempt to register the client as your browser's default handler for magnet links and automatically
open them in the new torrent screen for downloading.
} @else {
Magnet link registration is unavailable. Your
<a
href="https://caniuse.com/mdn-api_navigator_registerprotocolhandler"
target="_blank"
rel="noopener noreferrer"
>
browser may not support this feature
</a>
, or the client is not being served in a
<a
href="https://developer.mozilla.org/en-US/docs/Glossary/Secure_Context/"
target="_blank"
rel="noopener noreferrer"
>
secure context
</a>
.
}
</p>
</div>
</div>
}
@if (activeTab < 99) {
<div class="field">
<div class="control">
<button class="button is-success" (click)="ok()" [disabled]="saving" [ngClass]="{ 'is-loading': saving }">
Save Settings
</button>
</div>
</div>
}

View file

@ -1,13 +1,21 @@
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',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
imports: [NgClass, FormsModule, KeyValuePipe, Nl2BrPipe, FileSizePipe],
standalone: true,
})
export class SettingsComponent implements OnInit {
private settingsService = inject(SettingsService);
public activeTab = 0;
public tabs: Setting[] = [];
@ -27,18 +35,19 @@ export class SettingsComponent implements OnInit {
public testAria2cConnectionError: string = null;
public testAria2cConnectionSuccess: string = null;
constructor(private settingsService: SettingsService) {}
public canRegisterMagnetHandler = false;
ngOnInit(): void {
this.reset();
this.canRegisterMagnetHandler = !!(window.isSecureContext && 'registerProtocolHandler' in navigator);
}
public reset(): void {
this.settingsService.get().subscribe((settings) => {
this.tabs = settings.where((m) => m.key.indexOf(':') === -1);
this.tabs = settings.filter((m) => m.key.indexOf(':') === -1);
for (let tab of this.tabs) {
tab.settings = settings.where((m) => m.key.indexOf(`${tab.key}:`) > -1);
tab.settings = settings.filter((m) => m.key.indexOf(`${tab.key}:`) > -1);
}
});
}
@ -46,40 +55,39 @@ export class SettingsComponent implements OnInit {
public ok(): void {
this.saving = true;
const settingsToSave = this.tabs.selectMany((m) => m.settings).where((m) => m.type !== 'Object');
const settingsToSave = this.tabs.flatMap((m) => m.settings).filter((m) => m.type !== 'Object');
this.settingsService.update(settingsToSave).subscribe(
() => {
this.settingsService.update(settingsToSave).subscribe({
next: () =>
setTimeout(() => {
this.saving = false;
}, 1000);
},
(err) => {
}, 1000),
error: (err) => {
this.saving = false;
this.error = err;
}
);
},
});
}
public testDownloadPath(): void {
const settingDownloadPath = this.tabs
.first((m) => m.key === 'DownloadClient')
.settings.first((m) => m.key === 'DownloadClient:DownloadPath').value as string;
.find((m) => m.key === 'DownloadClient')
.settings.find((m) => m.key === 'DownloadClient:DownloadPath').value as string;
this.saving = true;
this.testPathError = null;
this.testPathSuccess = false;
this.settingsService.testPath(settingDownloadPath).subscribe(
() => {
this.settingsService.testPath(settingDownloadPath).subscribe({
next: () => {
this.saving = false;
this.testPathSuccess = true;
},
(err) => {
error: (err) => {
this.testPathError = err.error;
this.saving = false;
}
);
},
});
}
public testDownloadSpeed(): void {
@ -87,55 +95,66 @@ export class SettingsComponent implements OnInit {
this.testDownloadSpeedError = null;
this.testDownloadSpeedSuccess = 0;
this.settingsService.testDownloadSpeed().subscribe(
(result) => {
this.settingsService.testDownloadSpeed().subscribe({
next: (result) => {
this.saving = false;
this.testDownloadSpeedSuccess = result;
},
(err) => {
error: (err) => {
this.testDownloadSpeedError = err.error;
this.saving = false;
}
);
},
});
}
public testWriteSpeed(): void {
this.saving = true;
this.testWriteSpeedError = null;
this.testWriteSpeedSuccess = 0;
this.settingsService.testWriteSpeed().subscribe(
(result) => {
this.settingsService.testWriteSpeed().subscribe({
next: (result) => {
this.saving = false;
this.testWriteSpeedSuccess = result;
},
(err) => {
error: (err) => {
this.testWriteSpeedError = err.error;
this.saving = false;
}
);
},
});
}
public testAria2cConnection(): void {
const settingAria2cUrl = this.tabs
.first((m) => m.key === 'DownloadClient')
.settings.first((m) => m.key === 'DownloadClient:Aria2cUrl').value as string;
.find((m) => m.key === 'DownloadClient')
.settings.find((m) => m.key === 'DownloadClient:Aria2cUrl').value as string;
const settingAria2cSecret = this.tabs
.first((m) => m.key === 'DownloadClient')
.settings.first((m) => m.key === 'DownloadClient:Aria2cSecret').value as string;
.find((m) => m.key === 'DownloadClient')
.settings.find((m) => m.key === 'DownloadClient:Aria2cSecret').value as string;
this.saving = true;
this.testAria2cConnectionError = null;
this.testAria2cConnectionSuccess = null;
this.settingsService.testAria2cConnection(settingAria2cUrl, settingAria2cSecret).subscribe(
(result) => {
this.settingsService.testAria2cConnection(settingAria2cUrl, settingAria2cSecret).subscribe({
next: (result) => {
this.saving = false;
this.testAria2cConnectionSuccess = result.version;
},
(err) => {
error: (err) => {
this.testAria2cConnectionError = err.error;
this.saving = false;
}
);
},
});
}
public registerMagnetHandler(): void {
try {
navigator.registerProtocolHandler('magnet', `${window.location.origin}/add?magnet=%s`);
alert(
'Success! Your browser will now prompt you to confirm and add the client as the default handler for magnet links.',
);
} catch (error) {
alert('Magnet link registration failed.');
}
}
}

View file

@ -4,135 +4,188 @@
<div class="columns is-centered">
<div class="column is-5-tablet is-5-desktop is-5-widescreen">
<img src="assets/logo.png" />
<div class="box" *ngIf="step === 1">
<div class="notification is-primary">
Welcome to Real-Debrid Client. Please create your account by entering a username and password.
@if (step === 1) {
<div class="box">
<div class="notification is-primary">
Welcome to Real-Debrid Client. Please create your account by entering a username and password.
</div>
<form (ngSubmit)="setup()">
<div class="field">
<label for="userName" class="label">Username</label>
<div class="control has-icons-left">
<input
type="text"
placeholder=""
class="input"
name="userName"
id="userName"
[(ngModel)]="userName"
required
/>
<span class="icon is-small is-left">
<i class="fa fa-envelope"></i>
</span>
</div>
</div>
<div class="field">
<label for="password" class="label">Password</label>
<div class="control has-icons-left">
<input
type="password"
class="input"
required
name="password"
id="password"
[(ngModel)]="password"
/>
<span class="icon is-small is-left">
<i class="fa fa-lock"></i>
</span>
</div>
</div>
<div class="field">
<button class="button is-success" [ngClass]="{ 'is-loading': working }" [disabled]="working">
Setup
</button>
</div>
@if (error?.length > 0) {
<div class="notification is-danger is-light">
{{ error }}
</div>
}
</form>
</div>
<form (ngSubmit)="setup()">
}
@if (step === 2) {
<div class="box">
<div class="notification is-primary">
To be able to use the Real-Debrid Client you need a premium subscription to download torrents.
<br /><br />
Not premium yet? You have the choice of 5 providers:
<br />
<a href="https://real-debrid.com/?id=1348683" target="_blank" rel="noopener"
>Use this link to sign up to Real-Debrid.</a
>
<br />
<a href="https://alldebrid.com/?uid=2v91l&lang=en" target="_blank" rel="noopener"
>Use this link to sign up to AllDebrid.</a
>
<br />
<a href="https://www.premiumize.me/" target="_blank" rel="noopener"
>Use this link to sign up to Premiumize.</a
>
<br />
<a href="https://torbox.app/" target="_blank" rel="noopener">Use this link to sign up to TorBox.</a>
<br />
<a href="https://debrid-link.com/" target="_blank" rel="noopener"
>Use this link to sign up to DebridLink.</a
>
<small>(Referal links)</small>
</div>
<div class="field">
<label for="" class="label">Username</label>
<div class="control has-icons-left">
<input type="text" placeholder="" class="input" name="userName" [(ngModel)]="userName" required />
<span class="icon is-small is-left">
<i class="fa fa-envelope"></i>
</span>
<label class="label">Provider</label>
<div class="control select is-fullwidth">
<select [(ngModel)]="provider">
<option [ngValue]="0">Real-Debrid</option>
<option [ngValue]="1">AllDebrid</option>
<option [ngValue]="2">Premiumize</option>
<option [ngValue]="3">TorBox</option>
<option [ngValue]="4">DebridLink</option>
</select>
</div>
</div>
<div class="field">
<label for="" class="label" name="password">Password</label>
<div class="control has-icons-left">
<input type="password" class="input" required name="password" [(ngModel)]="password" />
<span class="icon is-small is-left">
<i class="fa fa-lock"></i>
</span>
<label for="debrid-api-token" class="label">API Private Token</label>
<div class="control">
<input
type="text"
placeholder=""
class="input"
name="token"
id="debrid-api-token"
[(ngModel)]="token"
required
/>
</div>
@if (provider === 0) {
<p class="help">
You can find your API key here:
<a href="https://real-debrid.com/apitoken" target="_blank" rel="noopener"
>https://real-debrid.com/apitoken</a
>.
</p>
}
@if (provider === 1) {
<p class="help">
You can find your API key here:
<a href="https://alldebrid.com/apikeys/" target="_blank" rel="noopener"
>https://alldebrid.com/apikeys/</a
>.
</p>
}
@if (provider === 2) {
<p class="help">
You can find your API key here:
<a href="https://www.premiumize.me/account" target="_blank" rel="noopener"
>https://www.premiumize.me/account</a
>.
</p>
}
@if (provider === 3) {
<p class="help">
You can find your API key here:
<a href="https://torbox.app/settings" target="_blank" rel="noopener">https://torbox.app/settings</a
>.
</p>
}
@if (provider === 4) {
<p class="help">
You can find your API key here:
<a href="https://debrid-link.com/webapp/apikey" target="_blank" rel="noopener"
>https://debrid-link.com/webapp/apikey</a
>.
</p>
}
</div>
<div class="field">
<button class="button is-success" [ngClass]="{ 'is-loading': working }" [disabled]="working">
<button
class="button is-success"
(click)="setToken()"
[ngClass]="{ 'is-loading': working }"
[disabled]="working"
>
Setup
</button>
</div>
<div class="notification is-danger is-light" *ngIf="error?.length > 0">
{{ error }}
</div>
</form>
</div>
<div class="box" *ngIf="step === 2">
<div class="notification is-primary">
To be able to use the Real-Debrid Client you need a premium subscription to download torrents.
<br /><br />
Not premium yet? You have the choice of 4 providers:
<br />
<a href="https://real-debrid.com/?id=1348683" target="_blank" rel="noopener"
>Use this link to sign up to Real-Debrid.</a
>
<br />
<a href="https://alldebrid.com/?uid=2v91l&lang=en" target="_blank" rel="noopener"
>Use this link to sign up to AllDebrid.</a
>
<br />
<a href="https://www.premiumize.me/" target="_blank" rel="noopener"
>Use this link to sign up to Premiumize.</a
>
<a href="https://torbox.app/" target="_blank" rel="noopener"
>Use this link to sign up to TorBox.</a>
<br />
<small>(Referal links)</small>
@if (error?.length > 0) {
<div class="notification is-danger is-light">
{{ error }}
</div>
}
</div>
<div class="field">
<label class="label">Provider</label>
<div class="control select is-fullwidth">
<select [(ngModel)]="provider">
<option [ngValue]="0">Real-Debrid</option>
<option [ngValue]="1">AllDebrid</option>
<option [ngValue]="2">Premiumize</option>
<option [ngValue]="3">TorBox</option>
</select>
}
@if (step === 3) {
<div class="box">
<div class="notification is-primary">
You are now connected to Real-Debrid! To setup Radarr or Sonarr, please following the instructions here:
<a href="https://github.com/rogerfar/rdt-client/" target="_blank" rel="noopener"
>https://github.com/rogerfar/rdt-client/</a
>.
</div>
<div class="field">
<button
class="button is-success"
(click)="close()"
[ngClass]="{ 'is-loading': working }"
[disabled]="working"
>
OK
</button>
</div>
</div>
<div class="field">
<label for="" class="label">API Private Token</label>
<div class="control">
<input type="text" placeholder="" class="input" name="token" [(ngModel)]="token" required />
</div>
<p class="help" *ngIf="provider === 0">
You can find your API key here:
<a href="https://real-debrid.com/apitoken" target="_blank" rel="noopener"
>https://real-debrid.com/apitoken</a
>.
</p>
<p class="help" *ngIf="provider === 1">
You can find your API key here:
<a href="https://alldebrid.com/apikeys/" target="_blank" rel="noopener"
>https://alldebrid.com/apikeys/</a
>.
</p>
<p class="help" *ngIf="provider === 2">
You can find your API key here:
<a href="https://www.premiumize.me/account" target="_blank" rel="noopener"
>https://www.premiumize.me/account</a
>.
</p>
<p class="help" *ngIf="provider === 3">
You can find your API key here:
<a href="https://torbox.app/settings" target="_blank" rel="noopener">https://torbox.app/settings</a>.
</p>
</div>
<div class="field">
<button
class="button is-success"
(click)="setToken()"
[ngClass]="{ 'is-loading': working }"
[disabled]="working"
>
Setup
</button>
</div>
<div class="notification is-danger is-light" *ngIf="error?.length > 0">
{{ error }}
</div>
</div>
<div class="box" *ngIf="step === 3">
<div class="notification is-primary">
You are now connected to Real-Debrid! To setup Radarr or Sonarr, please following the instructions here:
<a href="https://github.com/rogerfar/rdt-client/" target="_blank" rel="noopener"
>https://github.com/rogerfar/rdt-client/</a
>.
</div>
<div class="field">
<button
class="button is-success"
(click)="close()"
[ngClass]="{ 'is-loading': working }"
[disabled]="working"
>
OK
</button>
</div>
</div>
}
</div>
</div>
</div>

View file

@ -1,13 +1,20 @@
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';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-setup',
templateUrl: './setup.component.html',
styleUrls: ['./setup.component.scss'],
imports: [FormsModule, NgClass],
standalone: true,
})
export class SetupComponent {
private authService = inject(AuthService);
private router = inject(Router);
public userName: string;
public password: string;
public provider = 0;
@ -18,35 +25,33 @@ export class SetupComponent {
public step: number = 1;
constructor(private authService: AuthService, private router: Router) {}
public setup(): void {
this.error = null;
this.working = true;
this.authService.create(this.userName, this.password).subscribe(
() => {
this.authService.create(this.userName, this.password).subscribe({
next: () => {
this.step = 2;
this.working = false;
},
(err) => {
error: (err) => {
this.working = false;
this.error = err.error;
}
);
},
});
}
public setToken(): void {
this.authService.setupProvider(this.provider, this.token).subscribe(
() => {
this.authService.setupProvider(this.provider, this.token).subscribe({
next: () => {
this.step = 3;
this.working = false;
},
(err: any) => {
error: (err: any) => {
this.working = false;
this.error = err.error;
}
);
},
});
}
public close(): void {

View file

@ -1,22 +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

@ -1,108 +1,132 @@
import { Pipe, PipeTransform } from '@angular/core';
import { FileSizePipe } from 'ngx-filesize';
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.all((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.where((m) => m.downloadStarted && !m.downloadFinished && m.bytesDone > 0);
const downloaded = torrent.downloads.where((m) => m.downloadFinished != null);
if (downloading.length > 0) {
const bytesDone = downloading.sum((m) => m.bytesDone);
const bytesTotal = downloading.sum((m) => m.bytesTotal);
let progress = (bytesDone / bytesTotal) * 100;
if (isNaN(progress)) {
progress = 0;
}
let allSpeeds = downloading.sum((m) => m.speed);
let speed: string | string[] = '0';
speed = 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.where((m) => m.unpackingStarted && !m.unpackingFinished && m.bytesDone > 0);
const unpacked = torrent.downloads.where((m) => m.unpackingFinished != null);
if (unpacking.length > 0) {
const bytesDone = unpacking.sum((m) => m.bytesDone);
const bytesTotal = unpacking.sum((m) => m.bytesTotal);
let progress = (bytesDone / bytesTotal) * 100;
if (isNaN(progress)) {
progress = 0;
}
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.where((m) => m.unpackingQueued && !m.unpackingStarted);
if (queuedForUnpacking.length > 0) {
return `Queued for unpacking`;
if (download.unpackingFinished != null) {
unpackedCount += 1;
}
const queuedForDownload = torrent.downloads.where((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.Downloading:
if (torrent.rdSeeders < 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

@ -1,92 +1,137 @@
<div class="notification is-danger is-light" *ngIf="error && error.length > 0">
An error has occured: {{ error }}<br />
Please refresh the screen after fixing this error.
</div>
@if (error && error.length > 0) {
<div class="notification is-danger is-light">
An error has occurred: {{ error }}<br />
Please refresh the screen after fixing this error.
</div>
}
@if (diskSpaceStatus?.isPaused) {
<div class="notification is-warning">
<strong>Bezzad downloads paused due to low disk space</strong>
<br />
Available: {{ diskSpaceStatus.availableSpaceGB }} GB | Resume at: {{ diskSpaceStatus.thresholdGB * 2 }} GB
<br />
<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 />
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)="toggleSelectAll($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('status')">Status</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let torrent of torrents | sort: sortProperty : sortDirection; trackBy: trackByMethod">
<td>
<input
type="checkbox"
(click)="toggleSelect(torrent.torrentId)"
[checked]="selectedTorrents.contains(torrent.torrentId)"
/>
</td>
<td (click)="openTorrent(torrent.torrentId)">
{{ 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 | status }}
</td>
</tr>
</tbody>
</table>
@if (!isMobile) {
<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 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]="isSelected(torrent.torrentId)"
/>
</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>
}
</div>
}
<div fxLayout.lt-lg="column" fxLayout.gt-sm="row" fxLayoutGap="20px">
<button
class="button is-danger"
(click)="showDeleteModal()"
[disabled]="selectedTorrents.length === 0"
*ngIf="torrents.length > 0"
>
Delete Selected
</button>
<button
class="button is-primary"
(click)="showRetryModal()"
[disabled]="selectedTorrents.length === 0"
*ngIf="torrents.length > 0"
>
Retry Selected
</button>
<button
class="button is-primary"
(click)="changeSettingsModal()"
[disabled]="selectedTorrents.length === 0"
*ngIf="torrents.length > 0"
>
Change Settings
</button>
<div class="flex-container">
@if (torrents.length > 0) {
<button class="button is-danger" (click)="showDeleteModal()" [disabled]="selectedTorrents.length === 0">
Delete Selected
</button>
<button class="button is-primary" (click)="showRetryModal()" [disabled]="selectedTorrents.length === 0">
Retry Selected
</button>
<button class="button is-primary" (click)="changeSettingsModal()" [disabled]="selectedTorrents.length === 0">
Change Settings
</button>
}
</div>
</div>
@ -103,27 +148,33 @@
<label class="label"></label>
<div class="control">
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteData" />
<input type="checkbox" [(ngModel)]="deleteData" (change)="updateDeleteSelectAll()" />
Delete Torrents from client
</label>
<br />
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteRdTorrent" />
<input type="checkbox" [(ngModel)]="deleteRdTorrent" (change)="updateDeleteSelectAll()" />
Delete Torrents from provider
</label>
<br />
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteLocalFiles" />
<input type="checkbox" [(ngModel)]="deleteLocalFiles" (change)="updateDeleteSelectAll()" />
Delete local files
</label>
<br />
<br />
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteSelectAll" (change)="toggleDeleteSelectAllOptions()" />
Select All
</label>
</div>
</div>
<div class="notification is-primary">
Deleting a torrent from Real-Debrid will automatically delete it here too.
</div>
<div class="notification is-danger is-light" *ngIf="deleteError?.length > 0">
Error deleting torrent: {{ deleteError }}
</div>
@if (deleteError?.length > 0) {
<div class="notification is-danger is-light">Error deleting torrent: {{ deleteError }}</div>
}
</section>
<footer class="modal-card-foot">
<button
@ -154,9 +205,9 @@
This action will delete all the torrent data + all local downloads. Then it will re-add the original magnet link
or torrent file to the debrid provider.
</p>
<div class="notification is-danger is-light" *ngIf="retryError?.length > 0">
Error retrying torrent: {{ retryError }}
</div>
@if (retryError?.length > 0) {
<div class="notification is-danger is-light">Error retrying torrent: {{ retryError }}</div>
}
</section>
<footer class="modal-card-foot">
<button
@ -187,10 +238,10 @@
<div class="control select is-fullwidth">
<select [(ngModel)]="updateSettingsDownloadClient">
<option [ngValue]="null"></option>
<option [ngValue]="0">Internal Downloader</option>
<option [ngValue]="1">Bezzad</option>
<option [ngValue]="2">Aria2c</option>
<option [ngValue]="3">Symlink Downloader</option>
<option [ngValue]="0">Bezzad</option>
<option [ngValue]="1">Aria2c</option>
<option [ngValue]="2">Symlink Downloader</option>
<option [ngValue]="3">Synology DownloadStation</option>
</select>
</div>
<p class="help">
@ -285,9 +336,9 @@
</p>
</div>
<div class="notification is-danger is-light" *ngIf="changeSettingsError?.length > 0">
Error changing settings: {{ changeSettingsError }}
</div>
@if (changeSettingsError?.length > 0) {
<div class="notification is-danger is-light">Error changing settings: {{ changeSettingsError }}</div>
}
</section>
<footer class="modal-card-foot">
<button

View file

@ -1,5 +1,96 @@
table {
tr {
cursor: pointer;
td.break-all {
word-break: break-all;
}
}
}
.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 {
display: flex;
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,24 +1,41 @@
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';
import { RateLimitStatus } from '../models/rate-limit-status.model';
import { TorrentService } from '../torrent.service';
import { forkJoin, Observable } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { NgClass, DecimalPipe, DatePipe } from '@angular/common';
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, 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 = 'rdName';
public sortDirection: 'asc' | 'desc' = 'asc';
public sortProperty = 'added';
public sortDirection: SortDirection = 'desc';
public isDeleteModalActive: boolean;
public deleteError: string;
public deleting: boolean;
public deleteSelectAll: boolean;
public deleteData: boolean;
public deleteRdTorrent: boolean;
public deleteLocalFiles: boolean;
@ -40,57 +57,120 @@ export class TorrentTableComponent implements OnInit {
public updateSettingsDeleteOnError: number;
public updateSettingsTorrentLifetime: number;
constructor(
private router: Router,
private torrentService: TorrentService,
) {}
public diskSpaceStatus: DiskSpaceStatus | null = null;
public rateLimitStatus: RateLimitStatus | null = null;
public isMobile = false;
private mobileQuery: MediaQueryList;
private mobileQueryListener: (e: MediaQueryListEvent) => void;
ngOnInit(): void {
this.torrentService.getList().subscribe(
(result) => {
this.torrents = result;
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);
this.torrentService.update$.subscribe((result2) => {
this.torrents = result2;
});
},
(err) => {
this.error = err.error;
},
);
// Load persisted sort settings (if any)
try {
const sp = localStorage.getItem('torrentTable.sortProperty');
const sd = localStorage.getItem('torrentTable.sortDirection');
if (sp) {
this.sortProperty = sp;
}
if (sd === 'asc' || sd === 'desc') {
this.sortDirection = sd as 'asc' | 'desc';
}
} catch (_) {
// Ignore storage errors (e.g., disabled storage)
}
this.torrentService
.getDiskSpaceStatus()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (status) => {
this.diskSpaceStatus = status;
},
});
this.torrentService.diskSpaceStatus$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((status) => {
this.diskSpaceStatus = status;
});
this.torrentService
.getRateLimitStatus()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (status) => {
this.rateLimitStatus = status;
},
});
this.torrentService.rateLimitStatus$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((status) => {
this.rateLimitStatus = status;
});
this.torrentService.update$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => {
this.setTorrents(result);
});
this.torrentService
.getList()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
this.setTorrents(result);
},
error: (err) => {
this.error = err.error;
},
});
}
public sort(property: string): void {
this.sortProperty = property;
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
if (this.sortProperty === property) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortProperty = property;
this.sortDirection = 'desc';
}
// Persist sort settings
try {
localStorage.setItem('torrentTable.sortProperty', this.sortProperty);
localStorage.setItem('torrentTable.sortDirection', this.sortDirection);
} catch (_) {
// Ignore storage errors
}
this.applySorting();
}
public openTorrent(torrentId: string): void {
this.router.navigate([`/torrent/${torrentId}`]);
}
public trackByMethod(index: number, el: Torrent): string {
return el.torrentId;
}
public toggleDeleteSelectAll(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
public toggleSelectAll(event: any) {
this.selectedTorrents = [];
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 {
@ -109,7 +189,7 @@ export class TorrentTableComponent implements OnInit {
public deleteOk(): void {
this.deleting = true;
let calls: Observable<void>[] = [];
const calls: Observable<void>[] = [];
this.selectedTorrents.forEach((torrentId) => {
calls.push(this.torrentService.delete(torrentId, this.deleteData, this.deleteRdTorrent, this.deleteLocalFiles));
@ -120,7 +200,7 @@ export class TorrentTableComponent implements OnInit {
this.isDeleteModalActive = false;
this.deleting = false;
this.selectedTorrents = [];
this.clearSelectedTorrents();
},
error: (err) => {
this.deleteError = err.error;
@ -142,7 +222,7 @@ export class TorrentTableComponent implements OnInit {
public retryOk(): void {
this.retrying = true;
let calls: Observable<void>[] = [];
const calls: Observable<void>[] = [];
this.selectedTorrents.forEach((torrentId) => {
calls.push(this.torrentService.retry(torrentId));
@ -153,7 +233,7 @@ export class TorrentTableComponent implements OnInit {
this.isRetryModalActive = false;
this.retrying = false;
this.selectedTorrents = [];
this.clearSelectedTorrents();
},
error: (err) => {
this.retryError = err.error;
@ -165,30 +245,40 @@ export class TorrentTableComponent implements OnInit {
public changeSettingsModal(): void {
this.changeSettingsError = null;
const selectedTorrents = this.torrents.where((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
const selectedTorrents = this.getSelectedTorrentModels();
this.updateSettingsDownloadClient =
selectedTorrents.distinctBy((m) => m.downloadClient).count() == 1 ? selectedTorrents[0].downloadClient : null;
this.updateSettingsHostDownloadAction =
selectedTorrents.distinctBy((m) => m.hostDownloadAction).count() == 1
? selectedTorrents[0].hostDownloadAction
: null;
this.updateSettingsCategory =
selectedTorrents.distinctBy((m) => m.category).count() == 1 ? selectedTorrents[0].category : null;
this.updateSettingsPriority =
selectedTorrents.distinctBy((m) => m.priority).count() == 1 ? selectedTorrents[0].priority : null;
this.updateSettingsDownloadRetryAttempts =
selectedTorrents.distinctBy((m) => m.downloadRetryAttempts).count() == 1
? selectedTorrents[0].downloadRetryAttempts
: null;
this.updateSettingsTorrentRetryAttempts =
selectedTorrents.distinctBy((m) => m.torrentRetryAttempts).count() == 1
? selectedTorrents[0].torrentRetryAttempts
: null;
this.updateSettingsDeleteOnError =
selectedTorrents.distinctBy((m) => m.deleteOnError).count() == 1 ? selectedTorrents[0].deleteOnError : null;
this.updateSettingsTorrentLifetime =
selectedTorrents.distinctBy((m) => m.lifetime).count() == 1 ? selectedTorrents[0].lifetime : null;
this.updateSettingsDownloadClient = selectedTorrents.every(
(m, _, arr) => m.downloadClient === arr[0].downloadClient,
)
? selectedTorrents[0].downloadClient
: null;
this.updateSettingsHostDownloadAction = selectedTorrents.every(
(m, _, arr) => m.hostDownloadAction === arr[0].hostDownloadAction,
)
? selectedTorrents[0].hostDownloadAction
: null;
this.updateSettingsCategory = selectedTorrents.every((m, _, arr) => m.category === arr[0].category)
? selectedTorrents[0].category
: null;
this.updateSettingsPriority = selectedTorrents.every((m, _, arr) => m.priority === arr[0].priority)
? selectedTorrents[0].priority
: null;
this.updateSettingsDownloadRetryAttempts = selectedTorrents.every(
(m, _, arr) => m.downloadRetryAttempts === arr[0].downloadRetryAttempts,
)
? selectedTorrents[0].downloadRetryAttempts
: null;
this.updateSettingsTorrentRetryAttempts = selectedTorrents.every(
(m, _, arr) => m.torrentRetryAttempts === arr[0].torrentRetryAttempts,
)
? selectedTorrents[0].torrentRetryAttempts
: null;
this.updateSettingsDeleteOnError = selectedTorrents.every((m, _, arr) => m.deleteOnError === arr[0].deleteOnError)
? selectedTorrents[0].deleteOnError
: null;
this.updateSettingsTorrentLifetime = selectedTorrents.every((m, _, arr) => m.lifetime === arr[0].lifetime)
? selectedTorrents[0].lifetime
: null;
this.isChangeSettingsModalActive = true;
}
@ -200,9 +290,9 @@ export class TorrentTableComponent implements OnInit {
public changeSettingsOk(): void {
this.changingSettings = true;
let calls: Observable<void>[] = [];
const calls: Observable<void>[] = [];
const selectedTorrents = this.torrents.where((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
const selectedTorrents = this.getSelectedTorrentModels();
selectedTorrents.forEach((torrent) => {
if (this.updateSettingsDownloadClient != null) {
@ -218,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;
@ -238,7 +328,7 @@ export class TorrentTableComponent implements OnInit {
this.isChangeSettingsModalActive = false;
this.changingSettings = false;
this.selectedTorrents = [];
this.clearSelectedTorrents();
},
error: (err) => {
this.changeSettingsError = err.error;
@ -246,4 +336,65 @@ export class TorrentTableComponent implements OnInit {
},
});
}
toggleDeleteSelectAllOptions() {
this.deleteData = this.deleteSelectAll;
this.deleteRdTorrent = this.deleteSelectAll;
this.deleteLocalFiles = this.deleteSelectAll;
}
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,22 +1,26 @@
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';
import { DiskSpaceStatus } from './models/disk-space-status.model';
import { RateLimitStatus } from './models/rate-limit-status.model';
import { APP_BASE_HREF } from '@angular/common';
@Injectable({
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();
}
@ -29,11 +33,30 @@ export class TorrentService {
.withUrl(`${this.baseHref}hub`)
.withAutomaticReconnect()
.build();
this.connection.start().catch((err) => console.error(err));
this.connection.on('update', (torrents: Torrent[]) => {
this.update$.next(torrents);
});
this.connection.on('diskSpaceStatus', (status: any) => {
this.diskSpaceStatus$.next(status);
});
this.connection.on('rateLimitStatus', (status: any) => {
this.rateLimitStatus$.next(status);
});
this.connection.onreconnected(() => {
this.getDiskSpaceStatus().subscribe({
next: (status) => {
if (status) {
this.diskSpaceStatus$.next(status);
}
},
});
});
this.connection.start().catch((err) => console.error(err));
}
public getList(): Observable<Torrent[]> {
@ -44,6 +67,14 @@ export class TorrentService {
return this.http.get<Torrent>(`${this.baseHref}Api/Torrents/Get/${torrentId}`);
}
public getDiskSpaceStatus(): Observable<DiskSpaceStatus | null> {
return this.http.get<DiskSpaceStatus | null>(`${this.baseHref}Api/Torrents/DiskSpaceStatus`);
}
public getRateLimitStatus(): Observable<RateLimitStatus | null> {
return this.http.get<RateLimitStatus | null>(`${this.baseHref}Api/Torrents/RateLimitStatus`);
}
public uploadMagnet(magnetLink: string, torrent: Torrent): Observable<void> {
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadMagnet`, {
magnetLink,
@ -58,6 +89,20 @@ export class TorrentService {
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadFile`, formData);
}
public uploadNzbLink(nzbLink: string, torrent: Torrent): Observable<void> {
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadNzbLink`, {
nzbLink,
torrent,
});
}
public uploadNzbFile(file: File, torrent: Torrent): Observable<void> {
const formData: FormData = new FormData();
formData.append('file', file);
formData.append('formData', JSON.stringify({ torrent }));
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadNzbFile`, formData);
}
public checkFilesMagnet(magnetLink: string): Observable<TorrentFileAvailability[]> {
return this.http.post<TorrentFileAvailability[]>(`${this.baseHref}Api/Torrents/CheckFilesMagnet`, {
magnetLink,

View file

@ -12,352 +12,436 @@
</ul>
</div>
<div *ngIf="torrent === null || torrent === undefined">
<div class="fa-3x">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
<div *ngIf="torrent !== null && torrent !== undefined">
<div fxLayout.lt-lg="column" fxLayout.gt-sm="row" fxLayoutGap="20px" *ngIf="activeTab === 0">
<div fxFlex>
<div class="field is-grouped">
<div class="control">
<button class="button is-danger" (click)="showDeleteModal()">Delete Torrent</button>
</div>
<div class="control">
<button class="button is-primary" (click)="showRetryModal()">Retry Torrent</button>
</div>
<div class="control">
<button class="button is-light" (click)="showUpdateSettingsModal()">Change Settings</button>
</div>
</div>
<div class="field">
<label class="label">Status</label>
{{ torrent | status }}
</div>
<div class="field">
<label class="label">Retry count</label>
{{ torrent.retryCount }} / {{ torrent.torrentRetryAttempts }}
</div>
<div class="field">
<label class="label">Hash</label>
{{ torrent.hash }}
</div>
<div class="field">
<label class="label">Priority</label>
{{ torrent.priority || "" }}
</div>
<div class="field">
<label class="label">Category</label>
{{ torrent.category || "(no category set)" }}
</div>
<div class="field">
<label class="label">Downloader</label>
<ng-container [ngSwitch]="torrent.downloadClient">
<ng-container *ngSwitchCase="0">Internal Downloader</ng-container>
<ng-container *ngSwitchCase="1">Bezadd</ng-container>
<ng-container *ngSwitchCase="2">Aria2c</ng-container>
<ng-container *ngSwitchCase="3">Symlink Downloader</ng-container>
</ng-container>
</div>
<div class="field">
<label class="label">Post Download Action</label>
<ng-container [ngSwitch]="torrent.hostDownloadAction">
<ng-container *ngSwitchCase="0">Download all files to host</ng-container>
<ng-container *ngSwitchCase="1">Don't download files to host</ng-container>
</ng-container>
</div>
<div class="field">
<label class="label">Post Torrent Download Action</label>
<ng-container [ngSwitch]="torrent.downloadAction">
<ng-container *ngSwitchCase="0">Download all files above a certain size</ng-container>
<ng-container *ngSwitchCase="1"
>Download all available files on Real-Debrid above a certain size</ng-container
>
<ng-container *ngSwitchCase="2">Pick files I want to download</ng-container>
</ng-container>
</div>
<div class="field">
<label class="label">Finished action</label>
<ng-container [ngSwitch]="torrent.finishedAction">
<ng-container *ngSwitchCase="0">Do nothing</ng-container>
<ng-container *ngSwitchCase="1">Remove torrent from Real-Debrid and Real-Debrid Client</ng-container>
<ng-container *ngSwitchCase="2">Remove torrent from Real-Debrid</ng-container>
<ng-container *ngSwitchCase="3">Remove torrent from client</ng-container>
</ng-container>
</div>
<div class="field">
<label class="label">Minimum file size to download</label>
{{ torrent.downloadMinSize }}MB
</div>
<div class="field">
<label class="label">Include files</label>
{{ torrent.includeRegex }}
</div>
<div class="field">
<label class="label">Exclude files</label>
{{ torrent.excludeRegex }}
</div>
<div class="field" *ngIf="!torrent.isFile">
<label class="label">Magnet</label>
<span [cdkCopyToClipboard]="torrent.fileOrMagnet" (click)="copied = true" *ngIf="!copied"
>Click to copy magnet link to clipboard</span
>
<span *ngIf="copied">Link copied to clipboard!</span>
</div>
<div class="field" *ngIf="torrent.isFile">
<label class="label">Torrent file</label>
<span (click)="download()">Click to download torrent file</span>
</div>
<div class="field">
<label class="label">Added on</label>
{{ torrent.added | date: "fullDate" }} {{ torrent.added | date: "mediumTime" }}
</div>
<div class="field">
<label class="label">Files selected on</label>
<ng-container *ngIf="torrent.filesSelected === null">(no files selected yet) </ng-container>
<ng-container *ngIf="torrent.filesSelected !== null">
{{ torrent.filesSelected | date: "fullDate" }} {{ torrent.filesSelected | date: "mediumTime" }}
</ng-container>
</div>
<div class="field">
<label class="label">Completed on</label>
<ng-container *ngIf="torrent.completed === null">(not completed yet) </ng-container>
<ng-container *ngIf="torrent.completed !== null">
{{ torrent.completed | date: "fullDate" }} {{ torrent.completed | date: "mediumTime" }}
</ng-container>
</div>
</div>
<div fxFlex>
<div class="field">
<label class="label">Real-Debrid ID</label>
{{ torrent.rdId }}
</div>
<div class="field">
<label class="label">Real-Debrid Name</label>
{{ torrent.rdName }}
</div>
<div class="field">
<label class="label">Real-Debrid Size</label>
{{ torrent.rdSize | filesize }}
</div>
<div class="field">
<label class="label">Real-Debrid Host</label>
{{ torrent.rdHost }}
</div>
<div class="field">
<label class="label">Real-Debrid Split</label>
{{ torrent.rdSplit }}
</div>
<div class="field">
<label class="label">Real-Debrid Progress</label>
{{ torrent.rdProgress || 0 }}%
</div>
<div class="field">
<label class="label">Real-Debrid Status</label>
<ng-container [ngSwitch]="torrent.rdStatus">
<ng-container *ngSwitchCase="0">Processing</ng-container>
<ng-container *ngSwitchCase="1">Waiting For File Selection</ng-container>
<ng-container *ngSwitchCase="2">Downloading</ng-container>
<ng-container *ngSwitchCase="3">Finished</ng-container>
<ng-container *ngSwitchCase="99">Error</ng-container>
</ng-container>
({{ torrent.rdStatusRaw }})
</div>
<div class="field">
<label class="label">Real-Debrid Added</label>
{{ torrent.rdAdded | date: "fullDate" }} {{ torrent.rdAdded | date: "mediumTime" }}
</div>
<div class="field">
<label class="label">Real-Debrid Ended</label>
{{ torrent.rdEnded | date: "fullDate" }} {{ torrent.rdEnded | date: "mediumTime" }}
</div>
<div class="field">
<label class="label">Real-Debrid Speed</label>
{{ torrent.rdSpeed || 0 }}
</div>
<div class="field">
<label class="label">Real-Debrid Seeders</label>
{{ torrent.rdSeeders || "0" }}
</div>
@if (torrent === null || torrent === undefined) {
<div>
<div class="fa-3x">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
<div *ngIf="activeTab === 1">
<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>
<tr *ngFor="let file of torrent.files">
<td>
{{ file.id }}
</td>
<td>
{{ file.path }}
</td>
<td>
{{ file.bytes | filesize }}
</td>
<td>
<i class="fas fa-check" *ngIf="file.selected" style="color: green"></i>
<i class="fas fa-times" *ngIf="!file.selected" style="color: red"></i>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div *ngIf="activeTab === 2">
<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>
<ng-container *ngFor="let download of torrent.downloads">
<tr (click)="downloadExpanded[download.downloadId] = !downloadExpanded[download.downloadId]">
<td style="width: 35px">
<i class="fas fa-caret-right" *ngIf="!downloadExpanded[download.downloadId]"></i>
<i class="fas fa-caret-down" *ngIf="downloadExpanded[download.downloadId]"></i>
</td>
<td>
<ng-container *ngIf="download.link">
{{ download.link | decodeURI }}
</ng-container>
<ng-container *ngIf="!download.link">
{{ download.path }}
</ng-container>
</td>
<td>
{{ download.bytesTotal | filesize }}
</td>
<td>
{{ download | downloadStatus }}
</td>
</tr>
<tr *ngIf="downloadExpanded[download.downloadId]" class="separator">
<td style="width: 35px"></td>
<td colspan="5">
<div fxLayout.lt-lg="column" fxLayout.gt-sm="row" fxLayoutGap="20px">
<div fxFlex>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" (click)="showDownloadRetryModal(download.downloadId)">
Retry Download
</button>
} @else {
<div>
@if (activeTab === 0) {
<div class="flex-container">
<div style="flex: 1 1 0">
<div class="field is-grouped is-grouped-multiline action-buttons">
<div class="control">
<button class="button is-danger" (click)="showDeleteModal()">Delete Torrent</button>
</div>
<div class="control">
<button class="button is-primary" (click)="showRetryModal()">Retry Torrent</button>
</div>
<div class="control">
<button class="button is-light" (click)="showUpdateSettingsModal()">Change Settings</button>
</div>
</div>
<div class="field">
<label class="label">Status</label>
{{ torrent | status }}
</div>
<div class="field">
<label class="label">Retry count</label>
{{ torrent.retryCount }} / {{ torrent.torrentRetryAttempts }}
</div>
<div class="field">
<label class="label">Hash</label>
<span class="break-all">{{ torrent.hash }}</span>
</div>
<div class="field">
<label class="label">Priority</label>
{{ torrent.priority || "" }}
</div>
<div class="field">
<label class="label">Category</label>
{{ torrent.category || "(no category set)" }}
</div>
<div class="field">
<label class="label">Downloader</label>
@switch (torrent.downloadClient) {
@case (0) {
Bezadd
}
@case (1) {
Aria2c
}
@case (2) {
Symlink Downloader
}
@case (3) {
Synology DownloadStation
}
}
</div>
<div class="field">
<label class="label">Post Download Action</label>
@switch (torrent.hostDownloadAction) {
@case (0) {
Download all files to host
}
@case (1) {
Don't download files to host
}
}
</div>
<div class="field">
<label class="label">Post Torrent Download Action</label>
@switch (torrent.downloadAction) {
@case (0) {
Download all files above a certain size
}
@case (1) {
Download all available files on Real-Debrid above a certain size
}
@case (2) {
Pick files I want to download
}
}
</div>
<div class="field">
<label class="label">Finished action</label>
@switch (torrent.finishedAction) {
@case (0) {
Do nothing
}
@case (1) {
Remove torrent from Real-Debrid and Real-Debrid Client
}
@case (2) {
Remove torrent from Real-Debrid
}
@case (3) {
Remove torrent from client
}
}
</div>
<div class="field">
<label class="label">Finished action delay</label>
{{ torrent.finishedActionDelay || 0 }}
@if (torrent.finishedActionDelay === 1) {
minute
} @else {
minutes
}
</div>
<div class="field">
<label class="label">Minimum file size to download</label>
{{ torrent.downloadMinSize }}MB
</div>
<div class="field">
<label class="label">Include files</label>
{{ torrent.includeRegex }}
</div>
<div class="field">
<label class="label">Exclude files</label>
{{ torrent.excludeRegex }}
</div>
@if (!torrent.isFile) {
<div class="field">
<label class="label">Magnet</label>
@if (!copied) {
<span [cdkCopyToClipboard]="torrent.fileOrMagnet" (click)="copied = true">
Click to copy magnet link to clipboard
</span>
} @else {
<span>Link copied to clipboard!</span>
}
</div>
} @else {
<div class="field">
<label class="label">Torrent file</label>
<span (click)="download()">Click to download torrent file</span>
</div>
}
<div class="field">
<label class="label">Added on</label>
{{ torrent.added | date: "fullDate" }} {{ torrent.added | date: "mediumTime" }}
</div>
<div class="field">
<label class="label">Files selected on</label>
@if (torrent.filesSelected === null) {
(no files selected yet)
} @else {
{{ torrent.filesSelected | date: "fullDate" }} {{ torrent.filesSelected | date: "mediumTime" }}
}
</div>
<div class="field">
<label class="label">Completed on</label>
@if (torrent.completed === null) {
(not completed yet)
} @else {
{{ torrent.completed | date: "fullDate" }} {{ torrent.completed | date: "mediumTime" }}
}
</div>
</div>
<div style="flex: 1 1 0">
<div class="field">
<label class="label">Real-Debrid ID</label>
{{ torrent.rdId }}
</div>
<div class="field">
<label class="label">Real-Debrid Name</label>
{{ torrent.rdName }}
</div>
<div class="field">
<label class="label">Real-Debrid Size</label>
{{ torrent.rdSize | filesize }}
</div>
<div class="field">
<label class="label">Real-Debrid Host</label>
{{ torrent.rdHost }}
</div>
<div class="field">
<label class="label">Real-Debrid Split</label>
{{ torrent.rdSplit }}
</div>
<div class="field">
<label class="label">Real-Debrid Progress</label>
{{ torrent.rdProgress || 0 }}%
</div>
<div class="field">
<label class="label">Real-Debrid Status</label>
@switch (torrent.rdStatus) {
@case (0) {
Processing
}
@case (1) {
Waiting For File Selection
}
@case (2) {
Downloading
}
@case (3) {
Finished
}
@case (99) {
Error
}
}
({{ torrent.rdStatusRaw }})
</div>
<div class="field">
<label class="label">Real-Debrid Added</label>
{{ torrent.rdAdded | date: "fullDate" }} {{ torrent.rdAdded | date: "mediumTime" }}
</div>
<div class="field">
<label class="label">Real-Debrid Ended</label>
{{ torrent.rdEnded | date: "fullDate" }} {{ torrent.rdEnded | date: "mediumTime" }}
</div>
<div class="field">
<label class="label">Real-Debrid Speed</label>
{{ torrent.rdSpeed || 0 }}
</div>
<div class="field">
<label class="label">Real-Debrid Seeders</label>
{{ torrent.rdSeeders || "0" }}
</div>
</div>
</div>
}
@if (activeTab === 1) {
<div>
<div class="field">
<div class="table-container">
<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) {
<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">
<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>
<td>
{{ download | downloadStatus }}
</td>
</tr>
@if (expanded) {
<tr class="separator">
<td style="width: 35px"></td>
<td colspan="5">
<div class="flex-container">
<div style="flex: 1 1 0">
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" (click)="showDownloadRetryModal(download.downloadId)">
Retry Download
</button>
</div>
</div>
@if (download.error) {
<div class="field">
<label class="label">Error</label>
{{ download.error }}
</div>
}
<div class="field">
<label class="label">Real-Debrid Unrestricted Link</label>
@if (download.link) {
<a class="break-all" href="{{ download.link }}" target="_blank"> {{ download.link | decodeURI }}</a>
}
</div>
<div class="field break-all">
<label class="label">Real-Debrid Link</label>
{{ download.path }}
</div>
<div class="field">
<label class="label">Download</label>
{{ download.bytesDone | filesize }} / {{ download.bytesTotal | filesize }} ({{
download.speed | filesize
}}/s)
</div>
<div class="field">
<label class="label">Retry Count</label>
{{ download.retryCount }} / {{ torrent.downloadRetryAttempts }}
</div>
</div>
<div style="flex: 1 1 0">
<div class="field">
<label class="label">Added</label>
@if (download.added) {
{{ download.added | date: "fullDate" }} {{ download.added | date: "mediumTime" }}
} @else {
(not added yet)
}
</div>
<div class="field">
<label class="label">Download Queued</label>
@if (download.downloadQueued) {
{{ download.downloadQueued | date: "fullDate" }}
{{ download.downloadQueued | date: "mediumTime" }}
} @else {
(not queued for downloading yet)
}
</div>
<div class="field">
<label class="label">Download Started</label>
@if (download.downloadStarted) {
{{ download.downloadStarted | date: "fullDate" }}
{{ download.downloadStarted | date: "mediumTime" }}
} @else {
(not started downloading yet)
}
</div>
<div class="field">
<label class="label">Download Finished</label>
@if (download.downloadFinished) {
{{ download.downloadFinished | date: "fullDate" }}
{{ download.downloadFinished | date: "mediumTime" }}
} @else {
(not finished yet)
}
</div>
<div class="field">
<label class="label">Unpacking Queued</label>
@if (download.unpackingQueued) {
{{ download.unpackingQueued | date: "fullDate" }}
{{ download.unpackingQueued | date: "mediumTime" }}
} @else {
(not queued for unpacking yet)
}
</div>
<div class="field">
<label class="label">Unpacking Started</label>
@if (download.unpackingStarted) {
{{ download.unpackingStarted | date: "fullDate" }}
{{ download.unpackingStarted | date: "mediumTime" }}
} @else {
(not started unpacking yet)
}
</div>
<div class="field">
<label class="label">Unpacking Finished</label>
@if (download.unpackingFinished) {
{{ download.unpackingFinished | date: "fullDate" }}
{{ download.unpackingFinished | date: "mediumTime" }}
} @else {
(not finished unpacking yet)
}
</div>
<div class="field">
<label class="label">Completed</label>
@if (download.completed) {
{{ download.completed | date: "fullDate" }}
{{ download.completed | date: "mediumTime" }}
} @else {
(not completed yet)
}
</div>
</div>
</div>
</div>
<div class="field" *ngIf="download.error">
<label class="label">Error</label>
{{ download.error }}
</div>
<div class="field">
<label class="label">Real-Debrid Unrestricted Link</label>
<a href="{{ download.link }}" target="_blank" *ngIf="download.link">
{{ download.link | decodeURI }}</a
>
</div>
<div class="field">
<label class="label">Real-Debrid Link</label>
{{ download.path }}
</div>
<div class="field">
<label class="label">Download</label>
{{ download.bytesDone | filesize }} / {{ download.bytesTotal | filesize }} ({{
download.speed | filesize
}}/s)
</div>
<div class="field">
<label class="label">Retry Count</label>
{{ download.retryCount }} / {{ torrent.downloadRetryAttempts }}
</div>
</div>
<div fxFlex>
<div class="field">
<label class="label">Added</label>
<ng-container *ngIf="download.added">
{{ download.added | date: "fullDate" }} {{ download.added | date: "mediumTime" }}
</ng-container>
<ng-container *ngIf="!download.added">(not added yet) </ng-container>
</div>
<div class="field">
<label class="label">Download Queued</label>
<ng-container *ngIf="download.downloadQueued">
{{ download.downloadQueued | date: "fullDate" }}
{{ download.downloadQueued | date: "mediumTime" }}
</ng-container>
<ng-container *ngIf="!download.downloadQueued">(not queued for downloading yet) </ng-container>
</div>
<div class="field">
<label class="label">Download Started</label>
<ng-container *ngIf="download.downloadStarted">
{{ download.downloadStarted | date: "fullDate" }}
{{ download.downloadStarted | date: "mediumTime" }}
</ng-container>
<ng-container *ngIf="!download.downloadStarted">(not started downloading yet) </ng-container>
</div>
<div class="field">
<label class="label">Download Finished</label>
<ng-container *ngIf="download.downloadFinished">
{{ download.downloadFinished | date: "fullDate" }}
{{ download.downloadFinished | date: "mediumTime" }}
</ng-container>
<ng-container *ngIf="!download.downloadFinished">(not finished yet) </ng-container>
</div>
<div class="field">
<label class="label">Unpacking Queued</label>
<ng-container *ngIf="download.unpackingQueued">
{{ download.unpackingQueued | date: "fullDate" }}
{{ download.unpackingQueued | date: "mediumTime" }}
</ng-container>
<ng-container *ngIf="!download.unpackingQueued">(not queued for unpacking yet) </ng-container>
</div>
<div class="field">
<label class="label">Unpacking Started</label>
<ng-container *ngIf="download.unpackingStarted">
{{ download.unpackingStarted | date: "fullDate" }}
{{ download.unpackingStarted | date: "mediumTime" }}
</ng-container>
<ng-container *ngIf="!download.unpackingStarted">(not started unpacking yet) </ng-container>
</div>
<div class="field">
<label class="label">Unpacking Finished</label>
<ng-container *ngIf="download.unpackingFinished">
{{ download.unpackingFinished | date: "fullDate" }}
{{ download.unpackingFinished | date: "mediumTime" }}
</ng-container>
<ng-container *ngIf="!download.unpackingFinished">(not finished unpacking yet) </ng-container>
</div>
<div class="field">
<label class="label">Completed</label>
<ng-container *ngIf="download.completed">
{{ download.completed | date: "fullDate" }}
{{ download.completed | date: "mediumTime" }}
</ng-container>
<ng-container *ngIf="!download.completed">(not completed yet) </ng-container>
</div>
</div>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
</div>
}
<div class="modal" [class.is-active]="isDeleteModalActive">
<div class="modal-background"></div>
@ -372,27 +456,33 @@
<label class="label"></label>
<div class="control">
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteData" />
<input type="checkbox" [(ngModel)]="deleteData" (change)="updateDeleteSelectAll()" />
Delete Torrent from client
</label>
<br />
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteRdTorrent" />
<input type="checkbox" [(ngModel)]="deleteRdTorrent" (change)="updateDeleteSelectAll()" />
Delete Torrent from provider
</label>
<br />
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteLocalFiles" />
<input type="checkbox" [(ngModel)]="deleteLocalFiles" (change)="updateDeleteSelectAll()" />
Delete local files
</label>
<br />
<br />
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteSelectAll" (change)="toggleDeleteSelectAllOptions()" />
Select All
</label>
</div>
</div>
<div class="notification is-primary">
Deleting a torrent from Real-Debrid will automatically delete it here too.
</div>
<div class="notification is-danger is-light" *ngIf="deleteError?.length > 0">
Error deleting torrent: {{ deleteError }}
</div>
@if (deleteError?.length > 0) {
<div class="notification is-danger is-light">Error deleting torrent: {{ deleteError }}</div>
}
</section>
<footer class="modal-card-foot">
<button
@ -423,9 +513,9 @@
This action will delete all the torrent data + all local downloads. Then it will re-add the original magnet link
or torrent file to Real-Debrid.
</p>
<div class="notification is-danger is-light" *ngIf="retryError?.length > 0">
Error retrying torrent: {{ retryError }}
</div>
@if (retryError?.length > 0) {
<div class="notification is-danger is-light">Error retrying torrent: {{ retryError }}</div>
}
</section>
<footer class="modal-card-foot">
<button
@ -453,9 +543,9 @@
<section class="modal-card-body">
<p>Are you sure you want to retry this download?</p>
<p>This action will remove the local download and re-download the file from Real-Debrid.</p>
<div class="notification is-danger is-light" *ngIf="downloadRetryError?.length > 0">
Error retrying download: {{ downloadRetryError }}
</div>
@if (downloadRetryError?.length > 0) {
<div class="notification is-danger is-light">Error retrying download: {{ downloadRetryError }}</div>
}
</section>
<footer class="modal-card-foot">
<button
@ -494,10 +584,10 @@
<label class="label">Downloader</label>
<div class="control select is-fullwidth">
<select [(ngModel)]="updateSettingsDownloadClient">
<option [ngValue]="0">Internal Downloader</option>
<option [ngValue]="1">Bezzad</option>
<option [ngValue]="2">Aria2c</option>
<option [ngValue]="3">Symlink Downloader</option>
<option [ngValue]="0">Bezzad</option>
<option [ngValue]="1">Aria2c</option>
<option [ngValue]="2">Symlink Downloader</option>
<option [ngValue]="3">Synology DownloadStation</option>
</select>
</div>
<p class="help">

View file

@ -8,6 +8,38 @@ table {
}
}
.fa-download {
margin-left :12px;
}
.break-all {
word-break: break-all;
}
.flex-container {
display: flex;
flex: 1 1 0;
gap: 20px;
flex-direction: row;
@media screen and (max-width: 1279px) {
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,15 +1,41 @@
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';
import { TorrentService } from '../torrent.service';
import { NgClass, DatePipe } from '@angular/common';
import { CdkCopyToClipboard } from '@angular/cdk/clipboard';
import { FormsModule } from '@angular/forms';
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',
templateUrl: './torrent.component.html',
styleUrls: ['./torrent.component.scss'],
imports: [
NgClass,
CdkCopyToClipboard,
FormsModule,
DatePipe,
TorrentStatusPipe,
DownloadStatusPipe,
DecodeURIPipe,
FileSizePipe,
],
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;
@ -21,6 +47,7 @@ export class TorrentComponent implements OnInit {
public isDeleteModalActive: boolean;
public deleteError: string;
public deleting: boolean;
public deleteSelectAll: boolean;
public deleteData: boolean;
public deleteRdTorrent: boolean;
public deleteLocalFiles: boolean;
@ -47,48 +74,68 @@ 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.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.get(torrentId).subscribe(
(torrent) => {
this.torrent = torrent;
this.torrentService.update$.subscribe((result) => {
this.update(result);
});
},
() => {
this.router.navigate(['/']);
}
);
this.torrentService.update$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => {
this.update(result);
});
}
public update(torrents: Torrent[]): void {
const updatedTorrent = torrents.firstOrDefault((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);
var blob = new Blob([byteArray], { type: 'application/x-bittorrent' });
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`);
}
@ -110,18 +157,18 @@ export class TorrentComponent implements OnInit {
this.torrentService
.delete(this.torrent.torrentId, this.deleteData, this.deleteRdTorrent, this.deleteLocalFiles)
.subscribe(
() => {
.subscribe({
next: () => {
this.isDeleteModalActive = false;
this.deleting = false;
this.router.navigate(['/']);
},
(err) => {
error: (err) => {
this.deleteError = err.error;
this.deleting = false;
}
);
},
});
}
public showRetryModal(): void {
@ -137,18 +184,18 @@ export class TorrentComponent implements OnInit {
public retryOk(): void {
this.retrying = true;
this.torrentService.retry(this.torrent.torrentId).subscribe(
() => {
this.torrentService.retry(this.torrent.torrentId).subscribe({
next: () => {
this.isRetryModalActive = false;
this.retrying = false;
this.router.navigate(['/']);
},
(err) => {
error: (err) => {
this.retryError = err.error;
this.retrying = false;
}
);
},
});
}
public showDownloadRetryModal(downloadId: string): void {
@ -165,16 +212,16 @@ export class TorrentComponent implements OnInit {
public downloadRetryOk(): void {
this.downloadRetrying = true;
this.torrentService.retryDownload(this.downloadRetryId).subscribe(
() => {
this.torrentService.retryDownload(this.downloadRetryId).subscribe({
next: () => {
this.isDownloadRetryModalActive = false;
this.downloadRetrying = false;
},
(err) => {
error: (err) => {
this.downloadRetryError = err.error;
this.downloadRetrying = false;
}
);
},
});
}
public showUpdateSettingsModal(): void {
@ -206,15 +253,24 @@ export class TorrentComponent implements OnInit {
this.torrent.deleteOnError = this.updateSettingsDeleteOnError;
this.torrent.lifetime = this.updateSettingsTorrentLifetime;
this.torrentService.update(this.torrent).subscribe(
() => {
this.torrentService.update(this.torrent).subscribe({
next: () => {
this.isUpdateSettingsModalActive = false;
this.updating = false;
},
() => {
error: () => {
this.isUpdateSettingsModalActive = false;
this.updating = false;
}
);
},
});
}
toggleDeleteSelectAllOptions() {
this.deleteData = this.deleteSelectAll;
this.deleteRdTorrent = this.deleteSelectAll;
this.deleteLocalFiles = this.deleteSelectAll;
}
updateDeleteSelectAll() {
this.deleteSelectAll = this.deleteData && this.deleteRdTorrent && this.deleteLocalFiles;
}
}

View file

@ -6,11 +6,10 @@
<base href="/" />
<script>
(function () {
var baseElement = document.querySelector("base");
const baseElement = document.querySelector("base");
if (baseElement) {
var hrefValue = baseElement.getAttribute("href");
window["_app_base"] = hrefValue;
window["_app_base"] = baseElement.getAttribute("href");
console.log("setting base href to " + window["_app_base"]);
}
})();

View file

@ -1,13 +1,28 @@
import { enableProdMode } from '@angular/core';
import { enableProdMode, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { FileSizePipe } from './app/filesize.pipe';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { AuthInterceptor } from './app/auth.interceptor';
import { APP_BASE_HREF } from '@angular/common';
import { BrowserModule, bootstrapApplication } from '@angular/platform-browser';
import { AppRoutingModule } from './app/app-routing.module';
import { FormsModule } from '@angular/forms';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { AppComponent } from './app/app.component';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch((err) => console.error(err));
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));

View file

@ -1,2 +1,2 @@
@import "../node_modules/bulma/bulma.sass";
@import "../node_modules/@fortawesome/fontawesome-free/css/all.css";
@forward "../node_modules/bulma/bulma.scss";
@forward "../node_modules/@fortawesome/fontawesome-free/css/all.css";

View file

@ -5,6 +5,7 @@
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
@ -12,17 +13,12 @@
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"importHelpers": true,
"strictNullChecks": false,
"target": "ES2022",
"module": "es2020",
"lib": [
"es2020",
"dom"
],
"useDefineForClassFields": false
},
"angularCompilerOptions": {

4611
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
{
"name": "rdt-client",
"version": "2.0.90",
"description": "This is a web interface to manage your torrents on Real-Debrid.",
"main": "index.js",
"dependencies": {
"gh-release": "^7.0.2"
},
"devDependencies": {},
"repository": {
"type": "git",
"url": "git+https://github.com/rogerfar/rdt-client.git"
},
"author": "Roger Far",
"license": "MIT",
"bugs": {
"url": "https://github.com/rogerfar/rdt-client/issues"
},
"homepage": "https://github.com/rogerfar/rdt-client#readme"
}

View file

@ -15,7 +15,15 @@ public class DataContext(DbContextOptions options) : IdentityDbContext(options)
protected override void OnModelCreating(ModelBuilder builder)
{
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);
@ -25,4 +33,4 @@ public class DataContext(DbContextOptions options) : IdentityDbContext(options)
fk.DeleteBehavior = DeleteBehavior.Restrict;
}
}
}
}

View file

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

View file

@ -1,41 +1,66 @@
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)
{
return await dataContext.Downloads
.AsNoTracking()
.Where(m => m.TorrentId == torrentId)
.ToListAsync();
.AsNoTracking()
.Where(m => m.TorrentId == torrentId)
.ToListAsync();
}
public async Task<Download?> GetById(Guid downloadId)
{
return await dataContext.Downloads
.Include(m => m.Torrent)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.Include(m => m.Torrent)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
}
public async Task<Download?> Get(Guid torrentId, String path)
{
return await dataContext.Downloads
.Include(m => m.Torrent)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.TorrentId == torrentId && m.Path == path);
.Include(m => m.Torrent)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.TorrentId == torrentId && m.Path == path);
}
public async Task<Download> Add(Guid torrentId, String path)
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(),
TorrentId = torrentId,
Path = path,
FileName = downloadInfo.FileName,
Path = downloadInfo.RestrictedLink,
Added = DateTimeOffset.UtcNow,
DownloadQueued = DateTimeOffset.UtcNow,
RetryCount = 0
@ -43,17 +68,42 @@ public class DownloadData(DataContext dataContext)
await dataContext.Downloads.AddAsync(download);
await dataContext.SaveChangesAsync();
try
{
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
return DownloadAddResult.Added;
}
return download;
// 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)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -63,14 +113,27 @@ public class DownloadData(DataContext dataContext)
dbDownload.Link = unrestrictedLink;
await dataContext.SaveChangesAsync();
}
await TorrentData.VoidCache();
public async Task UpdateFileName(Guid downloadId, String fileName)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
return;
}
dbDownload.FileName = fileName;
await dataContext.SaveChangesAsync();
}
public async Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -80,31 +143,27 @@ public class DownloadData(DataContext dataContext)
dbDownload.DownloadStarted = dateTime;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
public async Task UpdateDownloadFinished(Guid downloadId, DateTimeOffset? dateTime)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
return;
}
dbDownload.DownloadFinished = dateTime;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
public async Task UpdateUnpackingQueued(Guid downloadId, DateTimeOffset? dateTime)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -114,14 +173,12 @@ public class DownloadData(DataContext dataContext)
dbDownload.UnpackingQueued = dateTime;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
public async Task UpdateUnpackingStarted(Guid downloadId, DateTimeOffset? dateTime)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -131,14 +188,12 @@ public class DownloadData(DataContext dataContext)
dbDownload.UnpackingStarted = dateTime;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
public async Task UpdateUnpackingFinished(Guid downloadId, DateTimeOffset? dateTime)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -148,14 +203,12 @@ public class DownloadData(DataContext dataContext)
dbDownload.UnpackingFinished = dateTime;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
public async Task UpdateCompleted(Guid downloadId, DateTimeOffset? dateTime)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -165,14 +218,12 @@ public class DownloadData(DataContext dataContext)
dbDownload.Completed = dateTime;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
public async Task UpdateError(Guid downloadId, String? error)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -182,14 +233,12 @@ public class DownloadData(DataContext dataContext)
dbDownload.Error = error;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
public async Task UpdateRetryCount(Guid downloadId, Int32 retryCount)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -199,14 +248,12 @@ public class DownloadData(DataContext dataContext)
dbDownload.RetryCount = retryCount;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
public async Task UpdateRemoteId(Guid downloadId, String remoteId)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
@ -221,26 +268,24 @@ public class DownloadData(DataContext dataContext)
public async Task DeleteForTorrent(Guid torrentId)
{
var downloads = await dataContext.Downloads
.Where(m => m.TorrentId == torrentId)
.ToListAsync();
.Where(m => m.TorrentId == torrentId)
.ToListAsync();
dataContext.Downloads.RemoveRange(downloads);
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
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)
.FirstOrDefaultAsync(m => m.DownloadId == downloadId)
?? throw new($"Cannot find download with ID {downloadId}");
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;
@ -250,7 +295,21 @@ public class DownloadData(DataContext dataContext)
dbDownload.Error = null;
await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
}
}
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

@ -0,0 +1,31 @@
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
namespace RdtClient.Data.Data;
public interface ITorrentData
{
Task<IList<Torrent>> Get();
Task<Torrent?> GetById(Guid torrentId);
Task<Torrent?> GetByHash(String hash);
Task<Torrent> Add(String? rdId,
String hash,
String? fileOrMagnetContents,
Boolean isFile,
DownloadType downloadType,
DownloadClient downloadClient,
Torrent torrent);
Task UpdateRdData(Torrent torrent);
Task UpdateRdId(Torrent torrent, String rdId);
Task UpdateHash(Torrent torrent, String hash);
Task Update(Torrent torrent);
Task UpdateCategory(Guid torrentId, String? category);
Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry);
Task UpdateFilesSelected(Guid torrentId, DateTimeOffset datetime);
Task UpdatePriority(Guid torrentId, Int32? priority);
Task UpdateRetry(Guid torrentId, DateTimeOffset? dateTime, Int32 retryCount);
Task UpdateError(Guid torrentId, String error);
Task Delete(Guid torrentId);
}

View file

@ -9,7 +9,8 @@ namespace RdtClient.Data.Data;
public class SettingData(DataContext dataContext, ILogger<SettingData> logger)
{
public static DbSettings Get { get; } = new();
// ReSharper disable once AutoPropertyCanBeMadeGetOnly.Local
public static DbSettings Get { get; private set; } = new();
public static IList<SettingProperty> GetAll()
{
@ -67,11 +68,14 @@ public class SettingData(DataContext dataContext, ILogger<SettingData> logger)
{
var dbSettings = await dataContext.Settings.AsNoTracking().ToListAsync();
var expectedSettings = GetSettings(Get, null).Where(m => m.Type != "Object").Select(m => new Setting
{
SettingId = m.Key,
Value = m.Value?.ToString()
}).ToList();
var expectedSettings = GetSettings(Get, null)
.Where(m => m.Type != "Object")
.Select(m => new Setting
{
SettingId = m.Key,
Value = m.Value?.ToString()
})
.ToList();
var newSettings = expectedSettings.Where(m => dbSettings.All(p => p.SettingId != m.SettingId)).ToList();
@ -215,4 +219,4 @@ public class SettingData(DataContext dataContext, ILogger<SettingData> logger)
}
}
}
}
}

View file

@ -1,40 +1,32 @@
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)
public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger = null) : ITorrentData
{
private static IList<Torrent>? _torrentCache;
private static readonly SemaphoreSlim TorrentCacheLock = new(1, 1);
public async Task<IList<Torrent>> Get()
{
await TorrentCacheLock.WaitAsync();
var torrents = await dataContext.Torrents
.AsNoTracking()
.AsSplitQuery()
.Include(m => m.Downloads)
.OrderBy(m => m.Priority ?? 9999)
.ToListAsync();
try
{
_torrentCache ??= await dataContext.Torrents
.AsNoTracking()
.Include(m => m.Downloads)
.ToListAsync();
return [.. _torrentCache.OrderBy(m => m.Priority ?? 9999).ThenBy(m => m.Added)];
}
finally
{
TorrentCacheLock.Release();
}
return torrents.OrderBy(m => m.Priority ?? 9999)
.ThenBy(m => m.Added)
.ToList();
}
public async Task<Torrent?> GetById(Guid torrentId)
{
var dbTorrent = await dataContext.Torrents
.AsNoTracking()
.Include(m => m.Downloads)
.FirstOrDefaultAsync(m => m.TorrentId == torrentId);
.AsNoTracking()
.Include(m => m.Downloads)
.FirstOrDefaultAsync(m => m.TorrentId == torrentId);
if (dbTorrent == null)
{
@ -54,9 +46,9 @@ public class TorrentData(DataContext dataContext)
hash = hash.ToLower();
var dbTorrent = await dataContext.Torrents
.AsNoTracking()
.Include(m => m.Downloads)
.FirstOrDefaultAsync(m => m.Hash == hash);
.AsNoTracking()
.Include(m => m.Downloads)
.FirstOrDefaultAsync(m => m.Hash == hash);
if (dbTorrent == null)
{
@ -71,10 +63,11 @@ public class TorrentData(DataContext dataContext)
return dbTorrent;
}
public async Task<Torrent> Add(String rdId,
public async Task<Torrent> Add(String? rdId,
String hash,
String? fileOrMagnetContents,
Boolean isFile,
DownloadType downloadType,
DownloadClient downloadClient,
Torrent torrent)
{
@ -86,6 +79,7 @@ public class TorrentData(DataContext dataContext)
Hash = hash.ToLower(),
Category = torrent.Category,
HostDownloadAction = torrent.HostDownloadAction,
FinishedActionDelay = torrent.FinishedActionDelay,
DownloadAction = torrent.DownloadAction,
FinishedAction = torrent.FinishedAction,
DownloadMinSize = torrent.DownloadMinSize,
@ -93,21 +87,22 @@ public class TorrentData(DataContext dataContext)
ExcludeRegex = torrent.ExcludeRegex,
DownloadManualFiles = torrent.DownloadManualFiles,
DownloadClient = downloadClient,
Type = downloadType,
FileOrMagnet = fileOrMagnetContents,
IsFile = isFile,
Priority = torrent.Priority,
TorrentRetryAttempts = torrent.TorrentRetryAttempts,
DownloadRetryAttempts = torrent.DownloadRetryAttempts,
DeleteOnError = torrent.DeleteOnError,
Lifetime = torrent.Lifetime
Lifetime = torrent.Lifetime,
RdStatus = torrent.RdStatus,
RdName = torrent.RdName
};
await dataContext.Torrents.AddAsync(newTorrent);
await dataContext.SaveChangesAsync();
await VoidCache();
return newTorrent;
}
@ -132,10 +127,36 @@ public class TorrentData(DataContext dataContext)
dbTorrent.RdSpeed = torrent.RdSpeed;
dbTorrent.RdSeeders = torrent.RdSeeders;
dbTorrent.RdFiles = torrent.RdFiles;
await dataContext.SaveChangesAsync();
await VoidCache();
await dataContext.SaveChangesAsync();
}
public async Task UpdateRdId(Torrent torrent, String rdId)
{
var dbTorrent = await dataContext.Torrents.FirstOrDefaultAsync(m => m.TorrentId == torrent.TorrentId);
if (dbTorrent == null)
{
return;
}
dbTorrent.RdId = rdId;
await dataContext.SaveChangesAsync();
}
public async Task UpdateHash(Torrent torrent, String hash)
{
var dbTorrent = await dataContext.Torrents.FirstOrDefaultAsync(m => m.TorrentId == torrent.TorrentId);
if (dbTorrent == null)
{
return;
}
dbTorrent.Hash = hash.ToLower();
await dataContext.SaveChangesAsync();
}
public async Task Update(Torrent torrent)
@ -157,8 +178,6 @@ public class TorrentData(DataContext dataContext)
dbTorrent.Lifetime = torrent.Lifetime;
await dataContext.SaveChangesAsync();
await VoidCache();
}
public async Task UpdateCategory(Guid torrentId, String? category)
@ -173,8 +192,6 @@ public class TorrentData(DataContext dataContext)
dbTorrent.Category = category;
await dataContext.SaveChangesAsync();
await VoidCache();
}
public async Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry)
@ -210,8 +227,6 @@ public class TorrentData(DataContext dataContext)
dbTorrent.Error = error;
await dataContext.SaveChangesAsync();
await VoidCache();
}
public async Task UpdateFilesSelected(Guid torrentId, DateTimeOffset datetime)
@ -226,8 +241,6 @@ public class TorrentData(DataContext dataContext)
dbTorrent.FilesSelected = datetime;
await dataContext.SaveChangesAsync();
await VoidCache();
}
public async Task UpdatePriority(Guid torrentId, Int32? priority)
@ -242,8 +255,6 @@ public class TorrentData(DataContext dataContext)
dbTorrent.Priority = priority;
await dataContext.SaveChangesAsync();
await VoidCache();
}
public async Task UpdateRetry(Guid torrentId, DateTimeOffset? dateTime, Int32 retryCount)
@ -259,8 +270,6 @@ public class TorrentData(DataContext dataContext)
dbTorrent.Retry = dateTime;
await dataContext.SaveChangesAsync();
await VoidCache();
}
public async Task UpdateError(Guid torrentId, String error)
@ -275,37 +284,27 @@ public class TorrentData(DataContext dataContext)
dbTorrent.Error = error;
await dataContext.SaveChangesAsync();
await VoidCache();
}
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();
await VoidCache();
}
public static async Task VoidCache()
{
await TorrentCacheLock.WaitAsync();
try
{
_torrentCache = null;
}
finally
{
TorrentCacheLock.Release();
}
}
}
}

View file

@ -9,4 +9,4 @@ public class UserData(DataContext dataContext)
{
return await dataContext.Users.OrderBy(m => m.Id).FirstOrDefaultAsync();
}
}
}

View file

@ -14,12 +14,12 @@ public static class DiConfig
throw new("Invalid database path found in appSettings");
}
var connectionString = $"Data Source={appSettings.Database.Path}";
var connectionString = $"Data Source={appSettings.Database.Path};Cache=Shared;Pooling=True;Command Timeout=30";
services.AddDbContext<DataContext>(options => options.UseSqlite(connectionString));
services.AddScoped<DownloadData>();
services.AddScoped<SettingData>();
services.AddScoped<TorrentData>();
services.AddScoped<ITorrentData, TorrentData>();
services.AddScoped<UserData>();
}
}
}

View file

@ -9,4 +9,4 @@ public enum AuthenticationType
[Description("No Authentication")]
None
}
}

View file

@ -4,9 +4,6 @@ namespace RdtClient.Data.Enums;
public enum DownloadClient
{
[Description("Internal Downloader")]
Internal,
[Description("Bezzad Downloader")]
Bezzad,
@ -15,4 +12,7 @@ public enum DownloadClient
[Description("Symlink Downloader")]
Symlink,
}
[Description("Synology DownloadStation")]
DownloadStation
}

View file

@ -21,4 +21,4 @@ public enum DownloadClientLogLevel
[Description("None")]
None
}
}

View file

@ -0,0 +1,7 @@
namespace RdtClient.Data.Enums;
public enum DownloadType
{
Torrent = 0,
Nzb = 1
}

View file

@ -18,4 +18,4 @@ public enum LogLevel
[Description("Error")]
Error
}
}

View file

@ -14,5 +14,8 @@ public enum Provider
Premiumize,
[Description("TorBox")]
TorBox
}
TorBox,
[Description("DebridLink")]
DebridLink
}

View file

@ -12,4 +12,4 @@ public enum TorrentDownloadAction
[Description("Manually Select Files")]
DownloadManual = 2
}
}

View file

@ -12,7 +12,7 @@ public enum TorrentFinishedAction
[Description("Remove Torrent From Provider")]
RemoveRealDebrid = 2,
[Description("Remove Torrent From Client")]
RemoveClient = 3
}
}

View file

@ -8,5 +8,5 @@ public enum TorrentHostDownloadAction
DownloadAll = 0,
[Description("Don't download any files to host")]
DownloadNone = 1,
}
DownloadNone = 1
}

View file

@ -2,11 +2,14 @@
public enum TorrentStatus
{
Processing = 0,
WaitingForFileSelection = 1,
Downloading = 2,
Finished = 3,
Uploading = 4,
// Queued by RDTClient, not the provider.
Queued = 0,
Processing = 1,
WaitingForFileSelection = 2,
Downloading = 3,
Finished = 4,
Uploading = 5,
Error = 99
}
}

View file

@ -0,0 +1,8 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Code style", Scope = "NamespaceAndDescendants", Target = "N:RdtClient.Data")]

View file

@ -0,0 +1,476 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RdtClient.Data.Data;
#nullable disable
namespace RdtClient.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20241210071621_Downloads_Add_FileName")]
partial class Downloads_Add_FileName
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.0");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.Property<Guid>("DownloadId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadStarted")
.HasColumnType("TEXT");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("Link")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RemoteId")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<Guid>("TorrentId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingStarted")
.HasColumnType("TEXT");
b.HasKey("DownloadId");
b.HasIndex("TorrentId");
b.ToTable("Downloads");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Setting", b =>
{
b.Property<string>("SettingId")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("SettingId");
b.ToTable("Settings");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Property<Guid>("TorrentId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<string>("Category")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<int>("DeleteOnError")
.HasColumnType("INTEGER");
b.Property<int>("DownloadAction")
.HasColumnType("INTEGER");
b.Property<int>("DownloadClient")
.HasColumnType("INTEGER");
b.Property<string>("DownloadManualFiles")
.HasColumnType("TEXT");
b.Property<int>("DownloadMinSize")
.HasColumnType("INTEGER");
b.Property<int>("DownloadRetryAttempts")
.HasColumnType("INTEGER");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("ExcludeRegex")
.HasColumnType("TEXT");
b.Property<string>("FileOrMagnet")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("FilesSelected")
.HasColumnType("TEXT");
b.Property<int>("FinishedAction")
.HasColumnType("INTEGER");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("HostDownloadAction")
.HasColumnType("INTEGER");
b.Property<string>("IncludeRegex")
.HasColumnType("TEXT");
b.Property<bool>("IsFile")
.HasColumnType("INTEGER");
b.Property<int>("Lifetime")
.HasColumnType("INTEGER");
b.Property<int?>("Priority")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("RdAdded")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RdEnded")
.HasColumnType("TEXT");
b.Property<string>("RdFiles")
.HasColumnType("TEXT");
b.Property<string>("RdHost")
.HasColumnType("TEXT");
b.Property<string>("RdId")
.HasColumnType("TEXT");
b.Property<string>("RdName")
.HasColumnType("TEXT");
b.Property<long?>("RdProgress")
.HasColumnType("INTEGER");
b.Property<long?>("RdSeeders")
.HasColumnType("INTEGER");
b.Property<long?>("RdSize")
.HasColumnType("INTEGER");
b.Property<long?>("RdSpeed")
.HasColumnType("INTEGER");
b.Property<long?>("RdSplit")
.HasColumnType("INTEGER");
b.Property<int?>("RdStatus")
.HasColumnType("INTEGER");
b.Property<string>("RdStatusRaw")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Retry")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<int>("TorrentRetryAttempts")
.HasColumnType("INTEGER");
b.HasKey("TorrentId");
b.ToTable("Torrents");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.HasOne("RdtClient.Data.Models.Data.Torrent", "Torrent")
.WithMany("Downloads")
.HasForeignKey("TorrentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Torrent");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Navigation("Downloads");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RdtClient.Data.Migrations
{
/// <inheritdoc />
public partial class Downloads_Add_FileName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FileName",
table: "Downloads",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FileName",
table: "Downloads");
}
}
}

View file

@ -0,0 +1,479 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RdtClient.Data.Data;
#nullable disable
namespace RdtClient.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20250106112750_Torrent_Add_ClientKind")]
partial class Torrent_Add_ClientKind
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.0");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.Property<Guid>("DownloadId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadStarted")
.HasColumnType("TEXT");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("Link")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RemoteId")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<Guid>("TorrentId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingStarted")
.HasColumnType("TEXT");
b.HasKey("DownloadId");
b.HasIndex("TorrentId");
b.ToTable("Downloads");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Setting", b =>
{
b.Property<string>("SettingId")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("SettingId");
b.ToTable("Settings");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Property<Guid>("TorrentId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<string>("Category")
.HasColumnType("TEXT");
b.Property<int?>("ClientKind")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<int>("DeleteOnError")
.HasColumnType("INTEGER");
b.Property<int>("DownloadAction")
.HasColumnType("INTEGER");
b.Property<int>("DownloadClient")
.HasColumnType("INTEGER");
b.Property<string>("DownloadManualFiles")
.HasColumnType("TEXT");
b.Property<int>("DownloadMinSize")
.HasColumnType("INTEGER");
b.Property<int>("DownloadRetryAttempts")
.HasColumnType("INTEGER");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("ExcludeRegex")
.HasColumnType("TEXT");
b.Property<string>("FileOrMagnet")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("FilesSelected")
.HasColumnType("TEXT");
b.Property<int>("FinishedAction")
.HasColumnType("INTEGER");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("HostDownloadAction")
.HasColumnType("INTEGER");
b.Property<string>("IncludeRegex")
.HasColumnType("TEXT");
b.Property<bool>("IsFile")
.HasColumnType("INTEGER");
b.Property<int>("Lifetime")
.HasColumnType("INTEGER");
b.Property<int?>("Priority")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("RdAdded")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RdEnded")
.HasColumnType("TEXT");
b.Property<string>("RdFiles")
.HasColumnType("TEXT");
b.Property<string>("RdHost")
.HasColumnType("TEXT");
b.Property<string>("RdId")
.HasColumnType("TEXT");
b.Property<string>("RdName")
.HasColumnType("TEXT");
b.Property<long?>("RdProgress")
.HasColumnType("INTEGER");
b.Property<long?>("RdSeeders")
.HasColumnType("INTEGER");
b.Property<long?>("RdSize")
.HasColumnType("INTEGER");
b.Property<long?>("RdSpeed")
.HasColumnType("INTEGER");
b.Property<long?>("RdSplit")
.HasColumnType("INTEGER");
b.Property<int?>("RdStatus")
.HasColumnType("INTEGER");
b.Property<string>("RdStatusRaw")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Retry")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<int>("TorrentRetryAttempts")
.HasColumnType("INTEGER");
b.HasKey("TorrentId");
b.ToTable("Torrents");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.HasOne("RdtClient.Data.Models.Data.Torrent", "Torrent")
.WithMany("Downloads")
.HasForeignKey("TorrentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Torrent");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Navigation("Downloads");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RdtClient.Data.Migrations
{
/// <inheritdoc />
public partial class Torrent_Add_ClientKind : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ClientKind",
table: "Torrents",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ClientKind",
table: "Torrents");
}
}
}

View file

@ -0,0 +1,482 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RdtClient.Data.Data;
#nullable disable
namespace RdtClient.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20250706204358_AddFinishedActionDelay")]
partial class AddFinishedActionDelay
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.5");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.Property<Guid>("DownloadId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadStarted")
.HasColumnType("TEXT");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("Link")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RemoteId")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<Guid>("TorrentId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingStarted")
.HasColumnType("TEXT");
b.HasKey("DownloadId");
b.HasIndex("TorrentId");
b.ToTable("Downloads");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Setting", b =>
{
b.Property<string>("SettingId")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("SettingId");
b.ToTable("Settings");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Property<Guid>("TorrentId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<string>("Category")
.HasColumnType("TEXT");
b.Property<int?>("ClientKind")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<int>("DeleteOnError")
.HasColumnType("INTEGER");
b.Property<int>("DownloadAction")
.HasColumnType("INTEGER");
b.Property<int>("DownloadClient")
.HasColumnType("INTEGER");
b.Property<string>("DownloadManualFiles")
.HasColumnType("TEXT");
b.Property<int>("DownloadMinSize")
.HasColumnType("INTEGER");
b.Property<int>("DownloadRetryAttempts")
.HasColumnType("INTEGER");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("ExcludeRegex")
.HasColumnType("TEXT");
b.Property<string>("FileOrMagnet")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("FilesSelected")
.HasColumnType("TEXT");
b.Property<int>("FinishedAction")
.HasColumnType("INTEGER");
b.Property<int>("FinishedActionDelay")
.HasColumnType("INTEGER");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("HostDownloadAction")
.HasColumnType("INTEGER");
b.Property<string>("IncludeRegex")
.HasColumnType("TEXT");
b.Property<bool>("IsFile")
.HasColumnType("INTEGER");
b.Property<int>("Lifetime")
.HasColumnType("INTEGER");
b.Property<int?>("Priority")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("RdAdded")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RdEnded")
.HasColumnType("TEXT");
b.Property<string>("RdFiles")
.HasColumnType("TEXT");
b.Property<string>("RdHost")
.HasColumnType("TEXT");
b.Property<string>("RdId")
.HasColumnType("TEXT");
b.Property<string>("RdName")
.HasColumnType("TEXT");
b.Property<long?>("RdProgress")
.HasColumnType("INTEGER");
b.Property<long?>("RdSeeders")
.HasColumnType("INTEGER");
b.Property<long?>("RdSize")
.HasColumnType("INTEGER");
b.Property<long?>("RdSpeed")
.HasColumnType("INTEGER");
b.Property<long?>("RdSplit")
.HasColumnType("INTEGER");
b.Property<int?>("RdStatus")
.HasColumnType("INTEGER");
b.Property<string>("RdStatusRaw")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Retry")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<int>("TorrentRetryAttempts")
.HasColumnType("INTEGER");
b.HasKey("TorrentId");
b.ToTable("Torrents");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.HasOne("RdtClient.Data.Models.Data.Torrent", "Torrent")
.WithMany("Downloads")
.HasForeignKey("TorrentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Torrent");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Navigation("Downloads");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RdtClient.Data.Migrations
{
/// <inheritdoc />
public partial class AddFinishedActionDelay : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FinishedActionDelay",
table: "Torrents",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FinishedActionDelay",
table: "Torrents");
}
}
}

View file

@ -0,0 +1,482 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RdtClient.Data.Data;
#nullable disable
namespace RdtClient.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20251007021253_Settings_Migrate_InternalDownloader2")]
partial class Settings_Migrate_InternalDownloader2
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.Property<Guid>("DownloadId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadStarted")
.HasColumnType("TEXT");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("Link")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RemoteId")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<Guid>("TorrentId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingStarted")
.HasColumnType("TEXT");
b.HasKey("DownloadId");
b.HasIndex("TorrentId");
b.ToTable("Downloads");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Setting", b =>
{
b.Property<string>("SettingId")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("SettingId");
b.ToTable("Settings");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Property<Guid>("TorrentId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<string>("Category")
.HasColumnType("TEXT");
b.Property<int?>("ClientKind")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<int>("DeleteOnError")
.HasColumnType("INTEGER");
b.Property<int>("DownloadAction")
.HasColumnType("INTEGER");
b.Property<int>("DownloadClient")
.HasColumnType("INTEGER");
b.Property<string>("DownloadManualFiles")
.HasColumnType("TEXT");
b.Property<int>("DownloadMinSize")
.HasColumnType("INTEGER");
b.Property<int>("DownloadRetryAttempts")
.HasColumnType("INTEGER");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("ExcludeRegex")
.HasColumnType("TEXT");
b.Property<string>("FileOrMagnet")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("FilesSelected")
.HasColumnType("TEXT");
b.Property<int>("FinishedAction")
.HasColumnType("INTEGER");
b.Property<int>("FinishedActionDelay")
.HasColumnType("INTEGER");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("HostDownloadAction")
.HasColumnType("INTEGER");
b.Property<string>("IncludeRegex")
.HasColumnType("TEXT");
b.Property<bool>("IsFile")
.HasColumnType("INTEGER");
b.Property<int>("Lifetime")
.HasColumnType("INTEGER");
b.Property<int?>("Priority")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("RdAdded")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RdEnded")
.HasColumnType("TEXT");
b.Property<string>("RdFiles")
.HasColumnType("TEXT");
b.Property<string>("RdHost")
.HasColumnType("TEXT");
b.Property<string>("RdId")
.HasColumnType("TEXT");
b.Property<string>("RdName")
.HasColumnType("TEXT");
b.Property<long?>("RdProgress")
.HasColumnType("INTEGER");
b.Property<long?>("RdSeeders")
.HasColumnType("INTEGER");
b.Property<long?>("RdSize")
.HasColumnType("INTEGER");
b.Property<long?>("RdSpeed")
.HasColumnType("INTEGER");
b.Property<long?>("RdSplit")
.HasColumnType("INTEGER");
b.Property<int?>("RdStatus")
.HasColumnType("INTEGER");
b.Property<string>("RdStatusRaw")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Retry")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<int>("TorrentRetryAttempts")
.HasColumnType("INTEGER");
b.HasKey("TorrentId");
b.ToTable("Torrents");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.HasOne("RdtClient.Data.Models.Data.Torrent", "Torrent")
.WithMany("Downloads")
.HasForeignKey("TorrentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Torrent");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Navigation("Downloads");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RdtClient.Data.Migrations
{
/// <inheritdoc />
public partial class Settings_Migrate_InternalDownloader2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Settings SET Value = 0 WHERE SettingId = 'DownloadClient:Client' AND Value = 1");
migrationBuilder.Sql("UPDATE Settings SET Value = 1 WHERE SettingId = 'DownloadClient:Client' AND Value = 2");
migrationBuilder.Sql("UPDATE Settings SET Value = 2 WHERE SettingId = 'DownloadClient:Client' AND Value = 3");
migrationBuilder.Sql("UPDATE Settings SET Value = 3 WHERE SettingId = 'DownloadClient:Client' AND Value = 4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View file

@ -0,0 +1,485 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RdtClient.Data.Data;
#nullable disable
namespace RdtClient.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20251224022148_AddDownloadTypeToTorrent")]
partial class AddDownloadTypeToTorrent
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.Property<Guid>("DownloadId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadStarted")
.HasColumnType("TEXT");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("Link")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RemoteId")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<Guid>("TorrentId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingStarted")
.HasColumnType("TEXT");
b.HasKey("DownloadId");
b.HasIndex("TorrentId");
b.ToTable("Downloads");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Setting", b =>
{
b.Property<string>("SettingId")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("SettingId");
b.ToTable("Settings");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Property<Guid>("TorrentId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<string>("Category")
.HasColumnType("TEXT");
b.Property<int?>("ClientKind")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<int>("DeleteOnError")
.HasColumnType("INTEGER");
b.Property<int>("DownloadAction")
.HasColumnType("INTEGER");
b.Property<int>("DownloadClient")
.HasColumnType("INTEGER");
b.Property<string>("DownloadManualFiles")
.HasColumnType("TEXT");
b.Property<int>("DownloadMinSize")
.HasColumnType("INTEGER");
b.Property<int>("DownloadRetryAttempts")
.HasColumnType("INTEGER");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("ExcludeRegex")
.HasColumnType("TEXT");
b.Property<string>("FileOrMagnet")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("FilesSelected")
.HasColumnType("TEXT");
b.Property<int>("FinishedAction")
.HasColumnType("INTEGER");
b.Property<int>("FinishedActionDelay")
.HasColumnType("INTEGER");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("HostDownloadAction")
.HasColumnType("INTEGER");
b.Property<string>("IncludeRegex")
.HasColumnType("TEXT");
b.Property<bool>("IsFile")
.HasColumnType("INTEGER");
b.Property<int>("Lifetime")
.HasColumnType("INTEGER");
b.Property<int?>("Priority")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("RdAdded")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RdEnded")
.HasColumnType("TEXT");
b.Property<string>("RdFiles")
.HasColumnType("TEXT");
b.Property<string>("RdHost")
.HasColumnType("TEXT");
b.Property<string>("RdId")
.HasColumnType("TEXT");
b.Property<string>("RdName")
.HasColumnType("TEXT");
b.Property<long?>("RdProgress")
.HasColumnType("INTEGER");
b.Property<long?>("RdSeeders")
.HasColumnType("INTEGER");
b.Property<long?>("RdSize")
.HasColumnType("INTEGER");
b.Property<long?>("RdSpeed")
.HasColumnType("INTEGER");
b.Property<long?>("RdSplit")
.HasColumnType("INTEGER");
b.Property<int?>("RdStatus")
.HasColumnType("INTEGER");
b.Property<string>("RdStatusRaw")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Retry")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<int>("TorrentRetryAttempts")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("TorrentId");
b.ToTable("Torrents");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.HasOne("RdtClient.Data.Models.Data.Torrent", "Torrent")
.WithMany("Downloads")
.HasForeignKey("TorrentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Torrent");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Navigation("Downloads");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RdtClient.Data.Migrations
{
/// <inheritdoc />
public partial class AddDownloadTypeToTorrent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Type",
table: "Torrents",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Type",
table: "Torrents");
}
}
}

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", "8.0.3");
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
@ -233,6 +233,9 @@ namespace RdtClient.Data.Migrations
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("Link")
.HasColumnType("TEXT");
@ -260,7 +263,8 @@ namespace RdtClient.Data.Migrations
b.HasKey("DownloadId");
b.HasIndex("TorrentId");
b.HasIndex("TorrentId", "Path")
.IsUnique();
b.ToTable("Downloads");
});
@ -290,6 +294,9 @@ namespace RdtClient.Data.Migrations
b.Property<string>("Category")
.HasColumnType("TEXT");
b.Property<int?>("ClientKind")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
@ -326,6 +333,9 @@ namespace RdtClient.Data.Migrations
b.Property<int>("FinishedAction")
.HasColumnType("INTEGER");
b.Property<int>("FinishedActionDelay")
.HasColumnType("INTEGER");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT");
@ -393,6 +403,9 @@ namespace RdtClient.Data.Migrations
b.Property<int>("TorrentRetryAttempts")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("TorrentId");
b.ToTable("Torrents");

View file

@ -26,11 +26,13 @@ public class Download
public DateTimeOffset? Completed { get; set; }
public Int32 RetryCount { get; set; }
public String? Error { get; set; }
public String? RemoteId { get; set; }
public String? FileName { get; set; }
[NotMapped]
public Int64 BytesTotal { get; set; }
@ -39,4 +41,22 @@ public class Download
[NotMapped]
public Int64 Speed { get; set; }
}
}
/// <summary>
/// Used to create <see cref="Download" />s
/// </summary>
public class DownloadInfo
{
/// <summary>
/// The name of the file. Should not include directory.
/// If the filename is not known, set tn null and `GetFileName` will be called with the unrestricted link.
/// </summary>
public required String? FileName;
/// <summary>
/// The restricted link to download this download. If the debrid serice in question does not have restricted links, use
/// either a fake or the unrestricted link
/// </summary>
public required String RestrictedLink;
}

View file

@ -8,4 +8,4 @@ public class Setting
public String SettingId { get; set; } = null!;
public String? Value { get; set; }
}
}

View file

@ -2,21 +2,26 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.TorrentClient;
using RdtClient.Data.Models.DebridClient;
namespace RdtClient.Data.Models.Data;
public class Torrent
{
private String? _rdFiles;
private IList<DebridClientFile> _filesCache = [];
private Boolean _filesCacheInitialized;
[Key]
public Guid TorrentId { get; set; }
public String Hash { get; set; } = null!;
public String? Category { get; set; }
public TorrentDownloadAction DownloadAction { get; set; }
public TorrentFinishedAction FinishedAction { get; set; }
public Int32 FinishedActionDelay { get; set; }
public TorrentHostDownloadAction HostDownloadAction { get; set; }
public Int32 DownloadMinSize { get; set; }
public String? IncludeRegex { get; set; }
@ -29,6 +34,7 @@ public class Torrent
public DateTimeOffset? Completed { get; set; }
public DateTimeOffset? Retry { get; set; }
public DownloadType Type { get; set; }
public String? FileOrMagnet { get; set; }
public Boolean IsFile { get; set; }
@ -44,6 +50,7 @@ public class Torrent
[InverseProperty("Torrent")]
public IList<Download> Downloads { get; set; } = [];
public Provider? ClientKind { get; set; }
public String? RdId { get; set; }
public String? RdName { get; set; }
public Int64? RdSize { get; set; }
@ -56,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<TorrentClientFile> Files
public IList<DebridClientFile> Files
{
get
{
if (_filesCacheInitialized)
{
return _filesCache;
}
_filesCacheInitialized = true;
if (String.IsNullOrWhiteSpace(RdFiles))
{
return [];
_filesCache = [];
return _filesCache;
}
try
{
return JsonSerializer.Deserialize<List<TorrentClientFile>>(RdFiles) ?? [];
_filesCache = JsonSerializer.Deserialize<List<DebridClientFile>>(RdFiles) ?? [];
}
catch
{
return [];
_filesCache = [];
}
return _filesCache;
}
}
@ -92,4 +125,4 @@ public class Torrent
return DownloadManualFiles.Split(",");
}
}
}
}

View file

@ -1,8 +1,8 @@
namespace RdtClient.Data.Models.TorrentClient;
namespace RdtClient.Data.Models.DebridClient;
public class TorrentClientAvailableFile
public class DebridClientAvailableFile
{
public String Filename { get; set; } = default!;
public Int64 Filesize { get; set; }
}
}

View file

@ -1,9 +1,10 @@
namespace RdtClient.Data.Models.TorrentClient;
namespace RdtClient.Data.Models.DebridClient;
public class TorrentClientFile
public class DebridClientFile
{
public Int64 Id { get; set; }
public String Path { get; set; } = default!;
public Int64 Bytes { get; set; }
public Boolean Selected { get; set; }
}
public String? DownloadLink { get; set; }
}

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