Compare commits
195 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
858bd7c95c | ||
|
|
2822653d3b | ||
|
|
e8a86cf806 | ||
|
|
38530cd6fd | ||
|
|
a7fb1e3e9d | ||
|
|
5c8a078927 | ||
|
|
f369e97d6e | ||
|
|
a9013e0c2a | ||
|
|
c6f6008591 | ||
|
|
b8e467652b | ||
|
|
e1635a43a9 | ||
|
|
f5cee6e2f3 | ||
|
|
767038528a | ||
|
|
a82e895a03 | ||
|
|
68edd4d80d | ||
|
|
d6f747c8d8 | ||
|
|
4eb34cbef9 | ||
|
|
5b7b526390 | ||
|
|
49ab603708 | ||
|
|
1166e9ba99 | ||
|
|
a86056a3ef | ||
|
|
1645320199 | ||
|
|
0e00062992 | ||
|
|
157fc8b2f2 | ||
|
|
45279115dc | ||
|
|
489bf6d7e0 | ||
|
|
5518e7f06b | ||
|
|
84cbd5056f | ||
|
|
c769bd08f0 | ||
|
|
8142225ee9 | ||
|
|
284ef44e30 | ||
|
|
fe6a2da266 | ||
|
|
8e49e500ea | ||
|
|
b292df22fe | ||
|
|
7aa6c03fee | ||
|
|
62e286104a | ||
|
|
bd48ea02a0 | ||
|
|
bc9fb89ecb | ||
|
|
d68b8b9bfe | ||
|
|
cc977cfe29 | ||
|
|
4afee6938b | ||
|
|
6a4603617b | ||
|
|
a62d84779e | ||
|
|
6c5d1fb39d | ||
|
|
f5ea1e0f10 | ||
|
|
75ad8e848d | ||
|
|
2d8983050d | ||
|
|
cd1310c1e9 | ||
|
|
7c1169d0dd | ||
|
|
9ea2219df7 | ||
|
|
fd5ece0e4d | ||
|
|
8c7c384bce | ||
|
|
7f60f9de5a | ||
|
|
5d09053327 | ||
|
|
f2bfae5f85 | ||
|
|
3d21ca245c | ||
|
|
d6bf1845d8 | ||
|
|
147b1db4a5 | ||
|
|
b037809c0e | ||
|
|
32aa57a561 | ||
|
|
4dd6ae5626 | ||
|
|
4920700853 | ||
|
|
1efc682cca | ||
|
|
28b7c79a6b | ||
|
|
797c58e654 | ||
|
|
63fd8caee9 | ||
|
|
5c71ecc211 | ||
|
|
145ea9a8ff | ||
|
|
d576ee7f01 | ||
|
|
8664993ff6 | ||
|
|
2627e03e97 | ||
|
|
9c4ddb66ad | ||
|
|
0abdde346d | ||
|
|
ea9fc0b167 | ||
|
|
75e02cfb82 | ||
|
|
59cff632eb | ||
|
|
53f985d80d | ||
|
|
f717f18f8e | ||
|
|
d4e1502c60 | ||
|
|
5054ca4ae1 | ||
|
|
66bd4560a8 | ||
|
|
683ac44c12 | ||
|
|
351e1eb7c8 | ||
|
|
390ab390da | ||
|
|
042b1e32e4 | ||
|
|
095a25f04a | ||
|
|
a4706a5830 | ||
|
|
cf278e2964 | ||
|
|
0d6369750f | ||
|
|
76dc3e0b98 | ||
|
|
e2195944e4 | ||
|
|
004ed45144 | ||
|
|
6743bd472e | ||
|
|
1dd7ab9c8e | ||
|
|
c0bbab5fa4 | ||
|
|
41787077db | ||
|
|
67afe189b5 | ||
|
|
b7090eeb3a | ||
|
|
0b726792cb | ||
|
|
7ec977b36e | ||
|
|
a7dd01eb42 | ||
|
|
178270084e | ||
|
|
2743089b3f | ||
|
|
41f816ab80 | ||
|
|
81dd3dd9f8 | ||
|
|
0af0b41bb8 | ||
|
|
e01cd6a979 | ||
|
|
480d881d92 | ||
|
|
11ccf5a2d4 | ||
|
|
b7df2bfb15 | ||
|
|
7c94528847 | ||
|
|
f4e1cf4717 | ||
|
|
d023bddd24 | ||
|
|
8e5238844e | ||
|
|
9e69020df8 | ||
|
|
bcb24e3bfd | ||
|
|
e36a5a07e5 | ||
|
|
db511929cd | ||
|
|
6dedb621d1 | ||
|
|
2db2af4cea | ||
|
|
3eda321d64 | ||
|
|
f32e8316e2 | ||
|
|
0ca2517eb6 | ||
|
|
6d0cb6ceb5 | ||
|
|
f2dab9f6e1 | ||
|
|
ed397f8223 | ||
|
|
a023d33d90 | ||
|
|
6572ba752c | ||
|
|
ccee652572 | ||
|
|
d6020594ef | ||
|
|
d0242f235c | ||
|
|
991ecefe3c | ||
|
|
33d8c2d825 | ||
|
|
f25cef711b | ||
|
|
40be170677 | ||
|
|
a5f9cd353b | ||
|
|
e93e4f6f22 | ||
|
|
d76d26131c | ||
|
|
580c7132c6 | ||
|
|
e51e5cde45 | ||
|
|
a68eec1bfa | ||
|
|
0c5e38e01a | ||
|
|
7aca0eaedd | ||
|
|
da576ef2c4 | ||
|
|
01a26c614b | ||
|
|
871802ad34 | ||
|
|
0f8921e1fb | ||
|
|
6b85d7bbbc | ||
|
|
25c06ab6fc | ||
|
|
ca325aaac1 | ||
|
|
99b2ad8e27 | ||
|
|
b539e8933e | ||
|
|
a9648248f4 | ||
|
|
24321a55c9 | ||
|
|
028c9f06ec | ||
|
|
b74b5dd7a9 | ||
|
|
42ebd2c74b | ||
|
|
9454ea1bd1 | ||
|
|
6cef70b979 | ||
|
|
15af54ed24 | ||
|
|
47355030b5 | ||
|
|
a7bb8da1d5 | ||
|
|
371a13e060 | ||
|
|
e606501834 | ||
|
|
6db556ed5c | ||
|
|
f12f28a8bb | ||
|
|
57ea1b3881 | ||
|
|
ed218d76ce | ||
|
|
27398ba993 | ||
|
|
994f167ab3 | ||
|
|
c51f8cb503 | ||
|
|
1080143fce | ||
|
|
90c1092fe5 | ||
|
|
123c4f29c6 | ||
|
|
dadab50840 | ||
|
|
eb80e9083b | ||
|
|
02a97f3170 | ||
|
|
2e8a7ce080 | ||
|
|
48f00e0459 | ||
|
|
a24928f9eb | ||
|
|
a7cab6d0dc | ||
|
|
6909fb3851 | ||
|
|
f5f2760f80 | ||
|
|
08c1750def | ||
|
|
aa512d1a33 | ||
|
|
3626f8cb97 | ||
|
|
6277a4ca5e | ||
|
|
e48f1d91a4 | ||
|
|
2e7c97d023 | ||
|
|
2b95b8d714 | ||
|
|
c1e48d35a5 | ||
|
|
4fb9e972f5 | ||
|
|
070d80a6fa | ||
|
|
ed354e93a9 | ||
|
|
9d0528902a |
210 changed files with 16143 additions and 5981 deletions
14
.devcontainer/Dockerfile
Normal file
14
.devcontainer/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM mcr.microsoft.com/devcontainers/dotnet:2-10.0-noble
|
||||
|
||||
ARG NODE_MAJOR=22
|
||||
ARG ANGULAR_CLI_VERSION=21
|
||||
|
||||
RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs sqlite3 \
|
||||
&& npm install -g "@angular/cli@^${ANGULAR_CLI_VERSION}" \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /data/db /data/downloads \
|
||||
&& chown -R vscode:vscode /data
|
||||
51
.devcontainer/devcontainer.json
Normal file
51
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"name": "rdt-client",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"NODE_MAJOR": "22"
|
||||
}
|
||||
},
|
||||
"remoteUser": "vscode",
|
||||
"runArgs": ["--init"],
|
||||
"containerEnv": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"BASE_PATH": "",
|
||||
"DOTNET_USE_POLLING_FILE_WATCHER": "1",
|
||||
"CHOKIDAR_USEPOLLING": "true"
|
||||
},
|
||||
"forwardPorts": [4200, 6500],
|
||||
"portsAttributes": {
|
||||
"4200": {
|
||||
"label": "Angular client",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"6500": {
|
||||
"label": "RdtClient backend",
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
"source=rdtclient-data-db,target=/data/db,type=volume",
|
||||
"source=rdtclient-data-downloads,target=/data/downloads,type=volume",
|
||||
"source=rdtclient-nuget,target=/home/vscode/.nuget,type=volume",
|
||||
"source=rdtclient-npm-cache,target=/home/vscode/.npm,type=volume"
|
||||
],
|
||||
"postCreateCommand": "/bin/bash .devcontainer/post-create.sh",
|
||||
"postStartCommand": "/bin/bash .devcontainer/post-start.sh",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-dotnettools.csharp",
|
||||
"ms-dotnettools.csdevkit",
|
||||
"Angular.ng-template",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"settings": {
|
||||
"dotnet.defaultSolution": "server/RdtClient.sln",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
.devcontainer/post-create.sh
Executable file
16
.devcontainer/post-create.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
sudo install -d -m 0775 -o vscode -g vscode \
|
||||
/data/db \
|
||||
/data/downloads \
|
||||
/home/vscode/.nuget \
|
||||
/home/vscode/.nuget/NuGet \
|
||||
/home/vscode/.nuget/packages \
|
||||
/home/vscode/.npm
|
||||
|
||||
sudo chown -R vscode:vscode /data /home/vscode/.nuget /home/vscode/.npm
|
||||
|
||||
dotnet restore server/RdtClient.sln
|
||||
npm install --prefix client
|
||||
13
.devcontainer/post-start.sh
Executable file
13
.devcontainer/post-start.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
sudo install -d -m 0775 -o vscode -g vscode \
|
||||
/data/db \
|
||||
/data/downloads \
|
||||
/home/vscode/.nuget \
|
||||
/home/vscode/.nuget/NuGet \
|
||||
/home/vscode/.nuget/packages \
|
||||
/home/vscode/.npm
|
||||
|
||||
sudo chown -R vscode:vscode /data /home/vscode/.nuget /home/vscode/.npm
|
||||
4
.github/workflows/build-docker-image.yml
vendored
4
.github/workflows/build-docker-image.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
runs-on: ${{ matrix.config.runs-on }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version and patch .csproj
|
||||
run: |
|
||||
|
|
@ -127,7 +127,7 @@ jobs:
|
|||
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}@${{ needs.build.outputs.digest-arm64 }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker Hub Registry Description
|
||||
if: ${{ env.ENABLE_DOCKERHUB == 1 }}
|
||||
|
|
|
|||
12
.github/workflows/build-release.yaml
vendored
12
.github/workflows/build-release.yaml
vendored
|
|
@ -16,23 +16,23 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: '9'
|
||||
dotnet-version: '10'
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
working-directory: client
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Build Frontend
|
||||
working-directory: client
|
||||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
|
|||
6
.github/workflows/dotnet-test.yml
vendored
6
.github/workflows/dotnet-test.yml
vendored
|
|
@ -11,11 +11,11 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore server
|
||||
- name: Build
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ server/RdtClient.Web/appsettings.Development.json
|
|||
data
|
||||
Dockerfile.dev
|
||||
test.bat
|
||||
.DS_Store
|
||||
|
|
|
|||
123
CHANGELOG.md
123
CHANGELOG.md
|
|
@ -6,6 +6,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased
|
||||
|
||||
## [2.0.138] - 2026-06-05
|
||||
### Fixed
|
||||
- SABnzbd fixes.
|
||||
|
||||
## [2.0.137] - 2026-06-05
|
||||
### Fixed
|
||||
- Fix torbox save path mapping for single file downloads.
|
||||
|
||||
## [2.0.136] - 2026-05-30
|
||||
### Added
|
||||
- Added devcontainer development workflow.
|
||||
|
||||
### Changed
|
||||
- Upgraded torbox.net dependency.
|
||||
|
||||
## [2.0.135] - 2026-05-27
|
||||
### Changed
|
||||
- When adding a torrent through the qBittorrent endpoints, it will wait to see if the torrent gets added properly or errors out, resulting in a better experience for infringing files and Sonarr / Radarr.
|
||||
- Upgraded torbox.net dependency.
|
||||
- Support SABnzbd API key auth, thanks to @ALenfant!
|
||||
|
||||
## [2.0.134] - 2026-05-22
|
||||
### Changed
|
||||
- Upgraded torbox.net dependency.
|
||||
|
||||
## [2.0.133] - 2026-05-17
|
||||
### Added
|
||||
- Added NZB support for Premiumize, thanks to @ALenfant!
|
||||
|
||||
## [2.0.132] - 2026-05-17
|
||||
### Changed
|
||||
- Fixed for Cleanuparr
|
||||
- Upgrade Torbox.NET dependency
|
||||
|
||||
## [2.0.131] - 2026-05-14
|
||||
### Changed
|
||||
- Fixes to the rate limiting.
|
||||
|
||||
## [2.0.130] - 2026-04-28
|
||||
### Changed
|
||||
- Fixes to unit tests
|
||||
- Upgraded .NET and Angular dependencies
|
||||
- Added better qBittorrent capabilities for Cleanuparr
|
||||
- Performance fixes
|
||||
|
||||
## [2.0.129] - 2026-04-06
|
||||
### Added
|
||||
- Optimized mobile views, thanks to @sylvaindd!
|
||||
|
||||
## [2.0.128] - 2026-04-06
|
||||
### Changed
|
||||
- Fixed some qBittorrent endpoints.
|
||||
|
||||
## [2.0.127] - 2026-03-15
|
||||
### Added
|
||||
- Implement qBittorrent endpoint for counting torrents, thanks to @vinodmishra!
|
||||
|
||||
## [2.0.126] - 2026-03-14
|
||||
### Added
|
||||
- qBittorrent hash filtering on the /torrrents/info endpoint, thanks to @jfrconley!
|
||||
- Category select as a dropdown when adding a new torrent, thanks to @sylvaindd!
|
||||
|
||||
### Changed
|
||||
- Rate limiting fixes, thanks to @omgbeez!
|
||||
- AllDebrid Symlink fixes, thanks to @AlexandreVassard!
|
||||
|
||||
## [2.0.125] - 2026-03-01
|
||||
### Added
|
||||
- Added extra qBittorrent endpoints for Cleanuparr compatibility.
|
||||
|
||||
## [2.0.124] - 2026-02-23
|
||||
### Added
|
||||
- Added /version/api for qBittorrent to support clients that still want to talk to the old API.
|
||||
- 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.
|
||||
|
|
|
|||
18
Dockerfile
18
Dockerfile
|
|
@ -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
|
||||
|
|
@ -15,13 +15,13 @@ COPY root ./root
|
|||
RUN \
|
||||
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-alpine 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
|
||||
|
|
@ -67,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 \
|
||||
|
|
|
|||
64
README.md
64
README.md
|
|
@ -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, Premiumize TorBox or DebridLink. It supports the following features:
|
||||
This is a web interface to manage your torrents on Real-Debrid, AllDebrid, Premiumize, TorBox or DebridLink. It supports the following features:
|
||||
|
||||
- Add new torrents through magnets or files
|
||||
- Add usenet downloads through NZB files (TorBox 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, Premiumize or Torbox!**
|
||||
**You will need a Premium service at Real-Debrid, AllDebrid, Premiumize, Torbox or DebridLink!**
|
||||
|
||||
[Click here to sign up for Real-Debrid.](https://real-debrid.com/?id=1348683)
|
||||
|
||||
|
|
@ -34,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 **ASP.NET Core Runtime 9.0.0** 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.
|
||||
|
|
@ -56,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>```
|
||||
|
|
@ -109,17 +110,7 @@ If you use Proxmox for your homelab, you can run rdt-client in a linux container
|
|||
|
||||
### Download Clients
|
||||
|
||||
Currently there 5 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.
|
||||
Currently there 4 available download clients:
|
||||
|
||||
#### Bezzad Downloader
|
||||
|
||||
|
|
@ -175,7 +166,9 @@ It has the following options:
|
|||
|
||||
### Connecting Sonarr/Radarr
|
||||
|
||||
RdtClient emulates the qBittorrent web protocol and allow applications to use those APIs. This way you can use Sonarr and Radarr to download directly from RealDebrid.
|
||||
RdtClient emulates the qBittorrent web protocol and allows applications to use those APIs. This way you can use Sonarr and Radarr to download directly from RealDebrid.
|
||||
|
||||
#### Torrents
|
||||
|
||||
1. Login to Sonarr or Radarr and click `Settings`.
|
||||
1. Go to the `Download Client` tab and click the plus to add.
|
||||
|
|
@ -192,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.
|
||||
|
|
@ -203,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 -c production`.
|
||||
1. Open the Visual Studio 2019 project `RdtClient.sln` and `Publish` the `RdtClient.Web` to the given `PublishFolder` target.
|
||||
1. Open the Visual Studio 2025 project `RdtClient.sln` and `Publish` the `RdtClient.Web` to the given `PublishFolder` target.
|
||||
1. When debugging, make sure to run `RdtClient.Web.dll` and not `IISExpress`.
|
||||
1. The result is found in `Publish`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
const minorOnly = [];
|
||||
const minorOnly = ["eslint", "typescript"];
|
||||
const patchOnly = [];
|
||||
|
||||
module.exports = {
|
||||
target: (name) => {
|
||||
if (minorOnly.indexOf(name) > -1) {
|
||||
return 'minor';
|
||||
return "minor";
|
||||
}
|
||||
if (patchOnly.indexOf(name) > -1) {
|
||||
return 'patch';
|
||||
return "patch";
|
||||
}
|
||||
return 'latest';
|
||||
}
|
||||
return "latest";
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@
|
|||
"assets": ["src/favicon.ico", "src/assets"],
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": [],
|
||||
"browser": "src/main.ts"
|
||||
"browser": "src/main.ts",
|
||||
"stylePreprocessorOptions": {
|
||||
"sass": {
|
||||
"silenceDeprecations": ["if-function"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
|
|
@ -116,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": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6635
client/package-lock.json
generated
6635
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,46 +8,47 @@
|
|||
"watch": "ng build --watch --configuration development",
|
||||
"update": "ng update --force --allow-dirty @angular/cli @angular/core @angular/cdk",
|
||||
"lint": "ng lint",
|
||||
"prettier": "prettier --write \"./**/*.{ts,html,json}\""
|
||||
"prettier": "prettier --write .",
|
||||
"precommit": "npm run prettier && npm run lint"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.2.11",
|
||||
"@angular/cdk": "^19.2.16",
|
||||
"@angular/common": "^19.2.11",
|
||||
"@angular/compiler": "^19.2.11",
|
||||
"@angular/core": "^19.2.11",
|
||||
"@angular/forms": "^19.2.11",
|
||||
"@angular/platform-browser": "^19.2.11",
|
||||
"@angular/platform-browser-dynamic": "^19.2.11",
|
||||
"@angular/router": "^19.2.11",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@microsoft/signalr": "^8.0.7",
|
||||
"@angular/animations": "^21.2.10",
|
||||
"@angular/cdk": "^21.2.8",
|
||||
"@angular/common": "^21.2.10",
|
||||
"@angular/compiler": "^21.2.10",
|
||||
"@angular/core": "^21.2.10",
|
||||
"@angular/forms": "^21.2.10",
|
||||
"@angular/platform-browser": "^21.2.10",
|
||||
"@angular/platform-browser-dynamic": "^21.2.10",
|
||||
"@angular/router": "^21.2.10",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"bulma": "^1.0.4",
|
||||
"curray": "^1.0.12",
|
||||
"file-saver-es": "^2.0.5",
|
||||
"filesize": "^10.1.6",
|
||||
"filesize": "^11.0.17",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "^0.15.0"
|
||||
"zone.js": "^0.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/builder": "19.4.0",
|
||||
"@angular-eslint/eslint-plugin": "19.4.0",
|
||||
"@angular-eslint/eslint-plugin-template": "19.4.0",
|
||||
"@angular-eslint/schematics": "19.4.0",
|
||||
"@angular-eslint/template-parser": "19.4.0",
|
||||
"@angular/build": "^19.2.12",
|
||||
"@angular/cli": "^19.2.12",
|
||||
"@angular/compiler-cli": "^19.2.11",
|
||||
"@angular/language-service": "^19.2.11",
|
||||
"@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": "^22.15.18",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"eslint": "^9.27.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "5.8.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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
<div class="field">
|
||||
<div>
|
||||
<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,31 +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]="4">Synology DownloadStation</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">
|
||||
|
|
@ -67,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>
|
||||
|
||||
|
|
@ -143,10 +197,10 @@
|
|||
<select [(ngModel)]="finishedAction">
|
||||
<option [ngValue]="0">Do nothing</option>
|
||||
@if (downloadClient !== 2) {
|
||||
<option [ngValue]="1">Remove torrent from provider and client</option>
|
||||
<option [ngValue]="2">Remove torrent from provider</option>
|
||||
<option [ngValue]="1">Remove from provider and client</option>
|
||||
<option [ngValue]="2">Remove from provider</option>
|
||||
}
|
||||
<option [ngValue]="3">Remove torrent from client</option>
|
||||
<option [ngValue]="3">Remove from client</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -157,15 +211,31 @@
|
|||
<input class="input" type="number" [(ngModel)]="finishedActionDelay" />
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
|
@ -175,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">
|
||||
|
|
@ -187,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">
|
||||
|
|
@ -207,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>
|
||||
|
|
@ -226,11 +299,3 @@
|
|||
}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,40 @@
|
|||
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'],
|
||||
standalone: false,
|
||||
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;
|
||||
|
|
@ -44,45 +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,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
) {}
|
||||
public get category(): string {
|
||||
return this._category;
|
||||
}
|
||||
|
||||
public set category(value: string) {
|
||||
this._category = value;
|
||||
this.updateFilteredCategories();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.activatedRoute.queryParams.subscribe((params) => {
|
||||
this.activatedRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
if (params['type'] === 'nzb') {
|
||||
this.type = 'nzb';
|
||||
} else if (params['type'] === 'torrent') {
|
||||
this.type = 'torrent';
|
||||
}
|
||||
|
||||
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().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.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.finishedActionDelay = settings.first((m) => m.key == 'Gui:Default:FinishedActionDelay')?.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.settingsService
|
||||
.get()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((settings) => {
|
||||
const providerSetting = settings.find((m) => m.key === 'Provider:Provider');
|
||||
this.provider = providerSetting.enumValues[providerSetting.value as number];
|
||||
this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.value as number;
|
||||
|
||||
this.setFinishAction();
|
||||
});
|
||||
this.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string;
|
||||
const categoriesSetting = settings.find((m) => m.key === 'General:Categories')?.value as string;
|
||||
this.categories = (categoriesSetting ?? '')
|
||||
.split(',')
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0)
|
||||
.filter((c, i, arr) => arr.findIndex((a) => a.toLowerCase() === c.toLowerCase()) === i);
|
||||
const matchedCategory = this.categories.find((c) => c.toLowerCase() === (this.category ?? '').toLowerCase());
|
||||
if (matchedCategory) {
|
||||
this.category = matchedCategory;
|
||||
} else {
|
||||
this.updateFilteredCategories();
|
||||
}
|
||||
this.hostDownloadAction = this.downloadAction = settings.find((m) => m.key === 'Gui:Default:HostDownloadAction')
|
||||
?.value as number;
|
||||
this.downloadAction =
|
||||
settings.find((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
|
||||
this.finishedAction = settings.find((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
|
||||
this.finishedActionDelay = settings.find((m) => m.key == 'Gui:Default:FinishedActionDelay')?.value as number;
|
||||
this.downloadMinSize = settings.find((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
|
||||
this.includeRegex = settings.find((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
|
||||
this.excludeRegex = settings.find((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
|
||||
this.torrentRetryAttempts = settings.find((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
|
||||
this.downloadRetryAttempts = settings.find((m) => m.key === 'Gui:Default:DownloadRetryAttempts')
|
||||
?.value as number;
|
||||
this.torrentDeleteOnError = settings.find((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
|
||||
this.torrentLifetime = settings.find((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
|
||||
this.priority = settings.find((m) => m.key === 'Gui:Default:Priority')?.value as number;
|
||||
|
||||
this.setFinishAction();
|
||||
});
|
||||
}
|
||||
|
||||
private updateFilteredCategories(): void {
|
||||
if (!this.category) {
|
||||
this.filteredCategories = this.categories;
|
||||
return;
|
||||
}
|
||||
|
||||
const search = this.category.toLowerCase();
|
||||
this.filteredCategories = this.categories.filter((value) => value.toLowerCase().includes(search));
|
||||
}
|
||||
|
||||
public selectCategory(cat: string): void {
|
||||
this.category = cat;
|
||||
this.categoryDropdownOpen = false;
|
||||
}
|
||||
|
||||
public onCategoryBlur(): void {
|
||||
setTimeout(() => (this.categoryDropdownOpen = false), 150);
|
||||
}
|
||||
|
||||
public setFinishAction() {
|
||||
|
|
@ -95,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;
|
||||
}
|
||||
|
||||
|
|
@ -111,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;
|
||||
|
|
@ -138,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);
|
||||
}
|
||||
|
|
@ -169,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +272,9 @@ export class AddNewTorrentComponent implements OnInit {
|
|||
}
|
||||
|
||||
public checkFiles(): void {
|
||||
if (this.type === 'nzb') {
|
||||
return;
|
||||
}
|
||||
if (this.magnetLink && this.magnetLink === this.currentTorrentFile) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -213,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;
|
||||
|
|
@ -222,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;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
template: '<router-outlet></router-outlet>',
|
||||
styles: [],
|
||||
standalone: false,
|
||||
imports: [RouterOutlet],
|
||||
})
|
||||
export class AppComponent {}
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
import 'curray';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { APP_BASE_HREF } from '@angular/common';
|
||||
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
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';
|
||||
import { FileSizePipe } from './filesize.pipe';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
MainLayoutComponent,
|
||||
NavbarComponent,
|
||||
AddNewTorrentComponent,
|
||||
TorrentTableComponent,
|
||||
SettingsComponent,
|
||||
TorrentStatusPipe,
|
||||
DownloadStatusPipe,
|
||||
LoginComponent,
|
||||
SetupComponent,
|
||||
TorrentComponent,
|
||||
DecodeURIPipe,
|
||||
ProfileComponent,
|
||||
Nl2BrPipe,
|
||||
SortPipe,
|
||||
FileSizePipe,
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
imports: [BrowserModule, AppRoutingModule, FormsModule, ClipboardModule],
|
||||
providers: [
|
||||
FileSizePipe,
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
|
||||
{ provide: APP_BASE_HREF, useValue: (window as any)['_app_base'] || '/' },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,13 +1,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,7 +16,7 @@ export class AuthInterceptor implements HttpInterceptor {
|
|||
} else if (error && (error.status === 401 || error.status === 403)) {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
return throwError(error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import { APP_BASE_HREF } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
@Inject(APP_BASE_HREF) private baseHref: string,
|
||||
) {}
|
||||
private http = inject(HttpClient);
|
||||
private baseHref = inject(APP_BASE_HREF);
|
||||
|
||||
public isLoggedIn(): Observable<boolean> {
|
||||
return this.http.get<boolean>(`${this.baseHref}Api/Authentication/IsLoggedIn`);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'decodeURI',
|
||||
standalone: false,
|
||||
})
|
||||
@Pipe({ name: 'decodeURI' })
|
||||
export class DecodeURIPipe implements PipeTransform {
|
||||
transform(input: any) {
|
||||
return decodeURI(input);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import { Download } from './models/download.model';
|
||||
import { FileSizePipe } from './filesize.pipe';
|
||||
|
||||
@Pipe({
|
||||
name: 'downloadStatus',
|
||||
standalone: false,
|
||||
})
|
||||
@Pipe({ name: 'downloadStatus' })
|
||||
export class DownloadStatusPipe implements PipeTransform {
|
||||
constructor(private pipe: FileSizePipe) {}
|
||||
private pipe = inject(FileSizePipe);
|
||||
|
||||
transform(value: Download): string {
|
||||
if (!value) {
|
||||
|
|
@ -27,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)}%`;
|
||||
}
|
||||
|
|
@ -45,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');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { filesize } from 'filesize';
|
||||
|
||||
@Pipe({
|
||||
name: 'filesize',
|
||||
standalone: false,
|
||||
})
|
||||
@Pipe({ name: 'filesize' })
|
||||
export class FileSizePipe implements PipeTransform {
|
||||
private static transformOne(value: number, options?: any): string {
|
||||
return filesize(value, options).toString();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,24 +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'],
|
||||
standalone: false,
|
||||
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;
|
||||
}
|
||||
|
|
@ -30,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;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +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'],
|
||||
standalone: false,
|
||||
imports: [NavbarComponent, RouterOutlet],
|
||||
standalone: true,
|
||||
})
|
||||
export class MainLayoutComponent {
|
||||
constructor() {}
|
||||
|
|
|
|||
6
client/src/app/models/disk-space-status.model.ts
Normal file
6
client/src/app/models/disk-space-status.model.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export interface DiskSpaceStatus {
|
||||
isPaused: boolean;
|
||||
availableSpaceGB: number;
|
||||
thresholdGB: number;
|
||||
lastCheckTime: Date;
|
||||
}
|
||||
4
client/src/app/models/rate-limit-status.model.ts
Normal file
4
client/src/app/models/rate-limit-status.model.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface RateLimitStatus {
|
||||
nextDequeueTime: Date | null;
|
||||
secondsRemaining: number;
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ export class Torrent {
|
|||
public lifetime: number;
|
||||
|
||||
public priority: number;
|
||||
public type: DownloadType;
|
||||
public error: string;
|
||||
|
||||
public rdId: string;
|
||||
|
|
@ -43,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 {
|
||||
|
|
@ -73,3 +77,8 @@ export enum RealDebridStatus {
|
|||
|
||||
Error = 99,
|
||||
}
|
||||
|
||||
export enum DownloadType {
|
||||
Torrent = 0,
|
||||
Nzb = 1,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@
|
|||
|
||||
.notification {
|
||||
margin-top: 52px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,77 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { NavigationEnd, 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'],
|
||||
standalone: false,
|
||||
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,
|
||||
) {
|
||||
this.router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationEnd) {
|
||||
constructor() {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.showMobileMenu = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getProfile().subscribe((result) => {
|
||||
this.profile = result;
|
||||
this.settingsService
|
||||
.getProfile()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result) => {
|
||||
this.profile = result;
|
||||
|
||||
switch (result.provider) {
|
||||
case 'RealDebrid':
|
||||
this.providerLink = 'https://real-debrid.com/?id=1348683';
|
||||
break;
|
||||
case 'AllDebrid':
|
||||
this.providerLink = 'https://alldebrid.com/?uid=2v91l&lang=en';
|
||||
break;
|
||||
case 'Premiumize':
|
||||
this.providerLink = 'https://www.premiumize.me/';
|
||||
break;
|
||||
case 'TorBox':
|
||||
this.providerLink = 'https://torbox.app/';
|
||||
break;
|
||||
case 'DebridLink':
|
||||
this.providerLink = 'https://debrid-link.com/';
|
||||
break;
|
||||
}
|
||||
});
|
||||
switch (result.provider) {
|
||||
case 'RealDebrid':
|
||||
this.providerLink = 'https://real-debrid.com/?id=1348683';
|
||||
break;
|
||||
case 'AllDebrid':
|
||||
this.providerLink = 'https://alldebrid.com/?uid=2v91l&lang=en';
|
||||
break;
|
||||
case 'Premiumize':
|
||||
this.providerLink = 'https://www.premiumize.me/';
|
||||
break;
|
||||
case 'TorBox':
|
||||
this.providerLink = 'https://torbox.app/';
|
||||
break;
|
||||
case 'DebridLink':
|
||||
this.providerLink = 'https://debrid-link.com/';
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.settingsService.getVersion().subscribe((result) => {
|
||||
this.version = result.version;
|
||||
});
|
||||
this.settingsService
|
||||
.getVersion()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result) => {
|
||||
this.version = result.version;
|
||||
});
|
||||
}
|
||||
|
||||
public logout(): void {
|
||||
this.authService.logout().subscribe(
|
||||
() => {
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
(err) => {},
|
||||
);
|
||||
this.authService.logout().subscribe({ next: () => this.router.navigate(['/login']), error: console.error });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +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',
|
||||
standalone: false,
|
||||
})
|
||||
@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') {
|
||||
|
|
|
|||
|
|
@ -1,14 +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'],
|
||||
standalone: false,
|
||||
imports: [FormsModule, NgClass],
|
||||
standalone: true,
|
||||
})
|
||||
export class ProfileComponent {
|
||||
constructor(private authService: AuthService) {}
|
||||
private authService = inject(AuthService);
|
||||
|
||||
public username: string;
|
||||
public password: string;
|
||||
|
|
@ -22,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;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Profile } from './models/profile.model';
|
||||
import { Setting } from './models/setting.model';
|
||||
|
|
@ -10,10 +10,8 @@ import { Version } from './models/version.model';
|
|||
providedIn: 'root',
|
||||
})
|
||||
export class SettingsService {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
@Inject(APP_BASE_HREF) private baseHref: string,
|
||||
) {}
|
||||
private http = inject(HttpClient);
|
||||
private baseHref = inject(APP_BASE_HREF);
|
||||
|
||||
public get(): Observable<Setting[]> {
|
||||
return this.http.get<Setting[]>(`${this.baseHref}Api/Settings`);
|
||||
|
|
|
|||
|
|
@ -1,14 +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'],
|
||||
standalone: false,
|
||||
imports: [NgClass, FormsModule, KeyValuePipe, Nl2BrPipe, FileSizePipe],
|
||||
standalone: true,
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
public activeTab = 0;
|
||||
|
||||
public tabs: Setting[] = [];
|
||||
|
|
@ -30,8 +37,6 @@ export class SettingsComponent implements OnInit {
|
|||
|
||||
public canRegisterMagnetHandler = false;
|
||||
|
||||
constructor(private settingsService: SettingsService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reset();
|
||||
this.canRegisterMagnetHandler = !!(window.isSecureContext && 'registerProtocolHandler' in navigator);
|
||||
|
|
@ -39,10 +44,10 @@ export class SettingsComponent implements OnInit {
|
|||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -50,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 {
|
||||
|
|
@ -91,62 +95,64 @@ 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.');
|
||||
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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,33 @@
|
|||
</div>
|
||||
<form (ngSubmit)="setup()">
|
||||
<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" [(ngModel)]="userName" required />
|
||||
<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="" 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" [(ngModel)]="password" />
|
||||
<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>
|
||||
|
|
@ -81,9 +96,17 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="" class="label">API Private Token</label>
|
||||
<label for="debrid-api-token" class="label">API Private Token</label>
|
||||
<div class="control">
|
||||
<input type="text" placeholder="" class="input" name="token" [(ngModel)]="token" required />
|
||||
<input
|
||||
type="text"
|
||||
placeholder=""
|
||||
class="input"
|
||||
name="token"
|
||||
id="debrid-api-token"
|
||||
[(ngModel)]="token"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@if (provider === 0) {
|
||||
<p class="help">
|
||||
|
|
|
|||
|
|
@ -1,14 +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'],
|
||||
standalone: false,
|
||||
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;
|
||||
|
|
@ -19,38 +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 {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,78 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'sort',
|
||||
standalone: false,
|
||||
})
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,111 +2,131 @@ import { Pipe, PipeTransform } from '@angular/core';
|
|||
import { RealDebridStatus, Torrent } from './models/torrent.model';
|
||||
import { FileSizePipe } from './filesize.pipe';
|
||||
|
||||
@Pipe({
|
||||
name: 'status',
|
||||
standalone: false,
|
||||
})
|
||||
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.Queued:
|
||||
return 'Not Yet Added to Provider';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,69 +4,121 @@
|
|||
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)="toggleDeleteSelectAll($event)"
|
||||
[checked]="selectedTorrents.length > 0 && selectedTorrents.length === torrents.length"
|
||||
/>
|
||||
</th>
|
||||
<th (click)="sort('rdName')">Name</th>
|
||||
<th (click)="sort('category')">Category</th>
|
||||
<th (click)="sort('priority')">Priority</th>
|
||||
<th (click)="sort('rdSeeders')">Seeders</th>
|
||||
<th (click)="sort('files.length')">Files</th>
|
||||
<th (click)="sort('downloads.length')">Downloads</th>
|
||||
<th (click)="sort('rdSize')">Size</th>
|
||||
<th (click)="sort('added')">Requested</th>
|
||||
<th (click)="sort('status')">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (torrent of torrents | sort: sortProperty : sortDirection; track torrent.torrentId) {
|
||||
@if (!isMobile) {
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
(click)="toggleDeleteSelectAll($event)"
|
||||
[checked]="selectedTorrents.length > 0 && selectedTorrents.length === torrents.length"
|
||||
/>
|
||||
</th>
|
||||
<th (click)="sort('rdName')">Name</th>
|
||||
<th (click)="sort('category')">Category</th>
|
||||
<th (click)="sort('priority')">Priority</th>
|
||||
<th (click)="sort('rdSeeders')">Seeders</th>
|
||||
<th (click)="sort('files.length')">Files</th>
|
||||
<th (click)="sort('downloads.length')">Downloads</th>
|
||||
<th (click)="sort('rdSize')">Size</th>
|
||||
<th (click)="sort('added')">Requested</th>
|
||||
<th (click)="sort('status')">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (torrent of sortedTorrents; track torrent.torrentId) {
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" (click)="toggleSelect(torrent.torrentId)" [checked]="isSelected(torrent.torrentId)" />
|
||||
</td>
|
||||
<td (click)="openTorrent(torrent.torrentId)" class="break-all">
|
||||
{{ torrent.rdName }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.category }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.priority }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.rdSeeders }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.rdSize | filesize }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.added | date: "medium" }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.statusText ?? (torrent | status) }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<div class="mobile-cards">
|
||||
@for (torrent of sortedTorrents; track torrent.torrentId) {
|
||||
<div class="box mobile-card" [class.is-selected]="isSelected(torrent.torrentId)">
|
||||
<div class="mobile-card-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
(click)="toggleSelect(torrent.torrentId)"
|
||||
[checked]="selectedTorrents.contains(torrent.torrentId)"
|
||||
[checked]="isSelected(torrent.torrentId)"
|
||||
/>
|
||||
</td>
|
||||
<td (click)="openTorrent(torrent.torrentId)" class="break-all">
|
||||
{{ torrent.rdName }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.category }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.priority }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.rdSeeders }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.files.length | number }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.downloads.length | number }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.rdSize | filesize }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent.added | date: "medium" }}
|
||||
</td>
|
||||
<td>
|
||||
{{ torrent | status }}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<div class="mobile-card-body" (click)="openTorrent(torrent.torrentId)">
|
||||
<p class="mobile-card-name">{{ torrent.rdName }}</p>
|
||||
<div class="tags mb-2">
|
||||
<span class="tag is-info is-light">{{ torrent.statusText ?? (torrent | status) }}</span>
|
||||
@if (torrent.category) {
|
||||
<span class="tag is-light">{{ torrent.category }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-details">
|
||||
<span class="mobile-card-detail"
|
||||
><span class="detail-label">Size</span> {{ torrent.rdSize | filesize }}</span
|
||||
>
|
||||
<span class="mobile-card-detail"><span class="detail-label">Seeders</span> {{ torrent.rdSeeders }}</span>
|
||||
<span class="mobile-card-detail"
|
||||
><span class="detail-label">Files</span> {{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }}</span
|
||||
>
|
||||
<span class="mobile-card-detail"
|
||||
><span class="detail-label">Downloads</span> {{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex-container">
|
||||
@if (torrents.length > 0) {
|
||||
|
|
@ -186,11 +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]="4">Synology DownloadStation</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">
|
||||
|
|
|
|||
|
|
@ -7,13 +7,90 @@ table {
|
|||
}
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,36 @@
|
|||
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'],
|
||||
standalone: false,
|
||||
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;
|
||||
|
|
@ -42,53 +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 toggleDeleteSelectAll(event: any) {
|
||||
this.selectedTorrents = [];
|
||||
public toggleDeleteSelectAll(event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
|
||||
if (event.target.checked) {
|
||||
this.torrents.map((torrent) => {
|
||||
this.selectedTorrents.push(torrent.torrentId);
|
||||
});
|
||||
}
|
||||
this.selectedTorrentIds = checked ? new Set(this.torrents.map((torrent) => torrent.torrentId)) : new Set<string>();
|
||||
this.syncSelectedTorrents();
|
||||
}
|
||||
|
||||
public toggleSelect(torrentId: string) {
|
||||
const index = this.selectedTorrents.indexOf(torrentId);
|
||||
|
||||
if (index > -1) {
|
||||
this.selectedTorrents.splice(index, 1);
|
||||
if (this.selectedTorrentIds.has(torrentId)) {
|
||||
this.selectedTorrentIds.delete(torrentId);
|
||||
} else {
|
||||
this.selectedTorrents.push(torrentId);
|
||||
this.selectedTorrentIds.add(torrentId);
|
||||
}
|
||||
|
||||
this.syncSelectedTorrents();
|
||||
}
|
||||
|
||||
public isSelected(torrentId: string): boolean {
|
||||
return this.selectedTorrentIds.has(torrentId);
|
||||
}
|
||||
|
||||
public showDeleteModal(): void {
|
||||
|
|
@ -107,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));
|
||||
|
|
@ -118,7 +200,7 @@ export class TorrentTableComponent implements OnInit {
|
|||
this.isDeleteModalActive = false;
|
||||
this.deleting = false;
|
||||
|
||||
this.selectedTorrents = [];
|
||||
this.clearSelectedTorrents();
|
||||
},
|
||||
error: (err) => {
|
||||
this.deleteError = err.error;
|
||||
|
|
@ -140,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));
|
||||
|
|
@ -151,7 +233,7 @@ export class TorrentTableComponent implements OnInit {
|
|||
this.isRetryModalActive = false;
|
||||
this.retrying = false;
|
||||
|
||||
this.selectedTorrents = [];
|
||||
this.clearSelectedTorrents();
|
||||
},
|
||||
error: (err) => {
|
||||
this.retryError = err.error;
|
||||
|
|
@ -163,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;
|
||||
}
|
||||
|
|
@ -198,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) {
|
||||
|
|
@ -216,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;
|
||||
|
|
@ -236,7 +328,7 @@ export class TorrentTableComponent implements OnInit {
|
|||
this.isChangeSettingsModalActive = false;
|
||||
this.changingSettings = false;
|
||||
|
||||
this.selectedTorrents = [];
|
||||
this.clearSelectedTorrents();
|
||||
},
|
||||
error: (err) => {
|
||||
this.changeSettingsError = err.error;
|
||||
|
|
@ -253,4 +345,56 @@ export class TorrentTableComponent implements OnInit {
|
|||
updateDeleteSelectAll() {
|
||||
this.deleteSelectAll = this.deleteData && this.deleteRdTorrent && this.deleteLocalFiles;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.mobileQuery?.removeEventListener('change', this.mobileQueryListener);
|
||||
}
|
||||
|
||||
private setTorrents(torrents: Torrent[]): void {
|
||||
this.torrents = torrents;
|
||||
this.pruneSelectedTorrents();
|
||||
this.applySorting();
|
||||
}
|
||||
|
||||
private applySorting(): void {
|
||||
this.sortedTorrents = sortItems(this.torrents, this.sortProperty, this.sortDirection, (torrent, field) => {
|
||||
switch (field) {
|
||||
case 'files.length':
|
||||
return torrent.filesCount ?? torrent.files?.length ?? 0;
|
||||
case 'downloads.length':
|
||||
return torrent.downloadsCount ?? torrent.downloads?.length ?? 0;
|
||||
case 'status':
|
||||
return torrent.statusText ?? getTorrentStatus(torrent);
|
||||
default:
|
||||
return getSortFieldValue(torrent, field);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getSelectedTorrentModels(): Torrent[] {
|
||||
return this.torrents.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId));
|
||||
}
|
||||
|
||||
private pruneSelectedTorrents(): void {
|
||||
const torrentIds = new Set(this.torrents.map((torrent) => torrent.torrentId));
|
||||
|
||||
for (const torrentId of this.selectedTorrentIds) {
|
||||
if (!torrentIds.has(torrentId)) {
|
||||
this.selectedTorrentIds.delete(torrentId);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncSelectedTorrents();
|
||||
}
|
||||
|
||||
private clearSelectedTorrents(): void {
|
||||
this.selectedTorrentIds.clear();
|
||||
this.syncSelectedTorrents();
|
||||
}
|
||||
|
||||
private syncSelectedTorrents(): void {
|
||||
this.selectedTorrents = this.torrents
|
||||
.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId))
|
||||
.map((torrent) => torrent.torrentId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
@if (activeTab === 0) {
|
||||
<div class="flex-container">
|
||||
<div style="flex: 1 1 0">
|
||||
<div class="field is-grouped">
|
||||
<div class="field is-grouped is-grouped-multiline action-buttons">
|
||||
<div class="control">
|
||||
<button class="button is-danger" (click)="showDeleteModal()">Delete Torrent</button>
|
||||
</div>
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Hash</label>
|
||||
{{ torrent.hash }}
|
||||
<span class="break-all">{{ torrent.hash }}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Priority</label>
|
||||
|
|
@ -58,18 +58,15 @@
|
|||
<label class="label">Downloader</label>
|
||||
@switch (torrent.downloadClient) {
|
||||
@case (0) {
|
||||
Internal Downloader
|
||||
}
|
||||
@case (1) {
|
||||
Bezadd
|
||||
}
|
||||
@case (2) {
|
||||
@case (1) {
|
||||
Aria2c
|
||||
}
|
||||
@case (3) {
|
||||
@case (2) {
|
||||
Symlink Downloader
|
||||
}
|
||||
@case (4) {
|
||||
@case (3) {
|
||||
Synology DownloadStation
|
||||
}
|
||||
}
|
||||
|
|
@ -243,72 +240,75 @@
|
|||
@if (activeTab === 1) {
|
||||
<div>
|
||||
<div class="field">
|
||||
<table class="table is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Path</th>
|
||||
<th>Size</th>
|
||||
<th>Selected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (file of torrent.files; track file.id) {
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
{{ file.id }}
|
||||
</td>
|
||||
<td>
|
||||
{{ file.path }}
|
||||
</td>
|
||||
<td>
|
||||
{{ file.bytes | filesize }}
|
||||
</td>
|
||||
<td>
|
||||
@if (file.selected) {
|
||||
<i class="fas fa-check" style="color: green"></i>
|
||||
} @else {
|
||||
<i class="fas fa-times" style="color: red"></i>
|
||||
}
|
||||
</td>
|
||||
<th>ID</th>
|
||||
<th>Path</th>
|
||||
<th>Size</th>
|
||||
<th>Selected</th>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (file of torrent.files; track file.id) {
|
||||
<tr>
|
||||
<td>
|
||||
{{ file.id }}
|
||||
</td>
|
||||
<td class="break-all">
|
||||
{{ file.path }}
|
||||
</td>
|
||||
<td>
|
||||
{{ file.bytes | filesize }}
|
||||
</td>
|
||||
<td>
|
||||
@if (file.selected) {
|
||||
<i class="fas fa-check" style="color: green"></i>
|
||||
} @else {
|
||||
<i class="fas fa-times" style="color: red"></i>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (activeTab === 2) {
|
||||
<div>
|
||||
<div class="field">
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 35px"></th>
|
||||
<th>Link</th>
|
||||
<th>Size</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of torrent.downloads; track download.downloadId) {
|
||||
@let expanded = downloadExpanded[download.downloadId];
|
||||
<tr (click)="downloadExpanded[download.downloadId] = !expanded">
|
||||
<td style="width: 35px">
|
||||
@if (!expanded) {
|
||||
<i class="fas fa-caret-right"></i>
|
||||
} @else {
|
||||
<i class="fas fa-caret-down"></i>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (download.link) {
|
||||
{{ download.link | decodeURI }}
|
||||
}
|
||||
@if (!download.link) {
|
||||
{{ download.path }}
|
||||
}
|
||||
</td>
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 35px"></th>
|
||||
<th>Link</th>
|
||||
<th>Size</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of torrent.downloads; track download.downloadId) {
|
||||
@let expanded = downloadExpanded[download.downloadId];
|
||||
<tr (click)="downloadExpanded[download.downloadId] = !expanded">
|
||||
<td style="width: 35px">
|
||||
@if (!expanded) {
|
||||
<i class="fas fa-caret-right"></i>
|
||||
} @else {
|
||||
<i class="fas fa-caret-down"></i>
|
||||
}
|
||||
</td>
|
||||
<td class="break-all">
|
||||
@if (download.link) {
|
||||
{{ download.link | decodeURI }}
|
||||
}
|
||||
@if (!download.link) {
|
||||
{{ download.path }}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{ download.bytesTotal | filesize }}
|
||||
</td>
|
||||
|
|
@ -338,10 +338,10 @@
|
|||
<div class="field">
|
||||
<label class="label">Real-Debrid Unrestricted Link</label>
|
||||
@if (download.link) {
|
||||
<a href="{{ download.link }}" target="_blank"> {{ download.link | decodeURI }}</a>
|
||||
<a class="break-all" href="{{ download.link }}" target="_blank"> {{ download.link | decodeURI }}</a>
|
||||
}
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field break-all">
|
||||
<label class="label">Real-Debrid Link</label>
|
||||
{{ download.path }}
|
||||
</div>
|
||||
|
|
@ -434,8 +434,9 @@
|
|||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -583,11 +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]="4">Synology DownloadStation</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">
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ table {
|
|||
}
|
||||
}
|
||||
|
||||
.fa-download {
|
||||
margin-left: 12px;
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
|
|
@ -22,3 +22,24 @@ table {
|
|||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.action-buttons {
|
||||
.control {
|
||||
width: 100%;
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs ul {
|
||||
flex-wrap: wrap;
|
||||
|
||||
li {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +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'],
|
||||
standalone: false,
|
||||
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;
|
||||
|
|
@ -49,52 +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`);
|
||||
}
|
||||
|
||||
|
|
@ -116,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 {
|
||||
|
|
@ -143,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 {
|
||||
|
|
@ -171,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 {
|
||||
|
|
@ -212,16 +253,16 @@ 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;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -14,12 +14,11 @@
|
|||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"strictNullChecks": false,
|
||||
"target": "ES2022",
|
||||
"module": "es2020",
|
||||
"lib": ["es2020", "dom"],
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
server/RdtClient.Data/Data/DownloadAddResult.cs
Normal file
9
server/RdtClient.Data/Data/DownloadAddResult.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace RdtClient.Data.Data;
|
||||
|
||||
public enum DownloadAddResult
|
||||
{
|
||||
Added,
|
||||
AlreadyExists,
|
||||
TorrentMissing,
|
||||
InvalidInput
|
||||
}
|
||||
|
|
@ -1,37 +1,60 @@
|
|||
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, DownloadInfo downloadInfo)
|
||||
public async Task<DownloadAddResult> TryAddForTorrent(Guid torrentId, DownloadInfo downloadInfo)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(downloadInfo.RestrictedLink))
|
||||
{
|
||||
logger?.LogDebug("Skipped download creation because the restricted link was blank. TorrentId: {torrentId}", torrentId);
|
||||
|
||||
return DownloadAddResult.InvalidInput;
|
||||
}
|
||||
|
||||
if (!await dataContext.Torrents.AsNoTracking().AnyAsync(m => m.TorrentId == torrentId))
|
||||
{
|
||||
logger?.LogDebug("Skipped download creation because the torrent no longer exists. TorrentId: {torrentId}, Path: {path}", torrentId, downloadInfo.RestrictedLink);
|
||||
|
||||
return DownloadAddResult.TorrentMissing;
|
||||
}
|
||||
|
||||
if (await dataContext.Downloads.AsNoTracking().AnyAsync(m => m.TorrentId == torrentId && m.Path == downloadInfo.RestrictedLink))
|
||||
{
|
||||
logger?.LogDebug("Skipped download creation because it already exists. TorrentId: {torrentId}, Path: {path}", torrentId, downloadInfo.RestrictedLink);
|
||||
|
||||
return DownloadAddResult.AlreadyExists;
|
||||
}
|
||||
|
||||
var download = new Download
|
||||
{
|
||||
DownloadId = Guid.NewGuid(),
|
||||
|
|
@ -45,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)
|
||||
{
|
||||
|
|
@ -65,14 +113,12 @@ 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);
|
||||
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
|
||||
|
||||
if (dbDownload == null)
|
||||
{
|
||||
|
|
@ -82,14 +128,12 @@ public class DownloadData(DataContext dataContext)
|
|||
dbDownload.FileName = fileName;
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
await TorrentData.VoidCache();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
|
@ -99,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)
|
||||
{
|
||||
|
|
@ -133,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)
|
||||
{
|
||||
|
|
@ -150,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)
|
||||
{
|
||||
|
|
@ -167,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)
|
||||
{
|
||||
|
|
@ -184,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)
|
||||
{
|
||||
|
|
@ -201,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)
|
||||
{
|
||||
|
|
@ -218,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)
|
||||
{
|
||||
|
|
@ -240,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;
|
||||
|
|
@ -269,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ public interface ITorrentData
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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)
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) : ITorrentData
|
||||
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) : ITorrentData
|
|||
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)
|
||||
{
|
||||
|
|
@ -75,6 +67,7 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
String hash,
|
||||
String? fileOrMagnetContents,
|
||||
Boolean isFile,
|
||||
DownloadType downloadType,
|
||||
DownloadClient downloadClient,
|
||||
Torrent torrent)
|
||||
{
|
||||
|
|
@ -94,6 +87,7 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
ExcludeRegex = torrent.ExcludeRegex,
|
||||
DownloadManualFiles = torrent.DownloadManualFiles,
|
||||
DownloadClient = downloadClient,
|
||||
Type = downloadType,
|
||||
FileOrMagnet = fileOrMagnetContents,
|
||||
IsFile = isFile,
|
||||
Priority = torrent.Priority,
|
||||
|
|
@ -109,8 +103,6 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
await VoidCache();
|
||||
|
||||
return newTorrent;
|
||||
}
|
||||
|
||||
|
|
@ -135,10 +127,8 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
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)
|
||||
|
|
@ -149,12 +139,24 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
dbTorrent.RdId = rdId;
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await VoidCache();
|
||||
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)
|
||||
|
|
@ -176,8 +178,6 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
dbTorrent.Lifetime = torrent.Lifetime;
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
await VoidCache();
|
||||
}
|
||||
|
||||
public async Task UpdateCategory(Guid torrentId, String? category)
|
||||
|
|
@ -192,8 +192,6 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
dbTorrent.Category = category;
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
await VoidCache();
|
||||
}
|
||||
|
||||
public async Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry)
|
||||
|
|
@ -229,8 +227,6 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
dbTorrent.Error = error;
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
await VoidCache();
|
||||
}
|
||||
|
||||
public async Task UpdateFilesSelected(Guid torrentId, DateTimeOffset datetime)
|
||||
|
|
@ -245,8 +241,6 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
dbTorrent.FilesSelected = datetime;
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
await VoidCache();
|
||||
}
|
||||
|
||||
public async Task UpdatePriority(Guid torrentId, Int32? priority)
|
||||
|
|
@ -261,8 +255,6 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
dbTorrent.Priority = priority;
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
await VoidCache();
|
||||
}
|
||||
|
||||
public async Task UpdateRetry(Guid torrentId, DateTimeOffset? dateTime, Int32 retryCount)
|
||||
|
|
@ -278,8 +270,6 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
dbTorrent.Retry = dateTime;
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
await VoidCache();
|
||||
}
|
||||
|
||||
public async Task UpdateError(Guid torrentId, String error)
|
||||
|
|
@ -294,37 +284,27 @@ public class TorrentData(DataContext dataContext) : ITorrentData
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ public class UserData(DataContext dataContext)
|
|||
{
|
||||
return await dataContext.Users.OrderBy(m => m.Id).FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ 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>();
|
||||
|
|
@ -22,4 +22,4 @@ public static class DiConfig
|
|||
services.AddScoped<ITorrentData, TorrentData>();
|
||||
services.AddScoped<UserData>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ public enum AuthenticationType
|
|||
|
||||
[Description("No Authentication")]
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ namespace RdtClient.Data.Enums;
|
|||
|
||||
public enum DownloadClient
|
||||
{
|
||||
[Description("Internal Downloader")]
|
||||
Internal,
|
||||
|
||||
[Description("Bezzad Downloader")]
|
||||
Bezzad,
|
||||
|
||||
|
|
@ -17,5 +14,5 @@ public enum DownloadClient
|
|||
Symlink,
|
||||
|
||||
[Description("Synology DownloadStation")]
|
||||
DownloadStation,
|
||||
}
|
||||
DownloadStation
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,4 @@ public enum DownloadClientLogLevel
|
|||
|
||||
[Description("None")]
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
server/RdtClient.Data/Enums/DownloadType.cs
Normal file
7
server/RdtClient.Data/Enums/DownloadType.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace RdtClient.Data.Enums;
|
||||
|
||||
public enum DownloadType
|
||||
{
|
||||
Torrent = 0,
|
||||
Nzb = 1
|
||||
}
|
||||
|
|
@ -18,4 +18,4 @@ public enum LogLevel
|
|||
|
||||
[Description("Error")]
|
||||
Error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@ public enum Provider
|
|||
|
||||
[Description("DebridLink")]
|
||||
DebridLink
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,4 @@ public enum TorrentDownloadAction
|
|||
|
||||
[Description("Manually Select Files")]
|
||||
DownloadManual = 2
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ public enum TorrentFinishedAction
|
|||
|
||||
[Description("Remove Torrent From Provider")]
|
||||
RemoveRealDebrid = 2,
|
||||
|
||||
|
||||
[Description("Remove Torrent From Client")]
|
||||
RemoveClient = 3
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ public enum TorrentHostDownloadAction
|
|||
DownloadAll = 0,
|
||||
|
||||
[Description("Don't download any files to host")]
|
||||
DownloadNone = 1,
|
||||
}
|
||||
DownloadNone = 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
public enum TorrentStatus
|
||||
{
|
||||
// Queued by RDTClient, not the provider.
|
||||
Queued = 0,
|
||||
|
||||
Processing = 1,
|
||||
|
|
@ -11,4 +12,4 @@ public enum TorrentStatus
|
|||
Uploading = 5,
|
||||
|
||||
Error = 99
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
[assembly: SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Code style", Scope = "NamespaceAndDescendants", Target = "N:RdtClient.Web")]
|
||||
[assembly: SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Code style", Scope = "NamespaceAndDescendants", Target = "N:RdtClient.Data")]
|
||||
|
|
|
|||
482
server/RdtClient.Data/Migrations/20251007021253_Settings_Migrate_InternalDownloader2.Designer.cs
generated
Normal file
482
server/RdtClient.Data/Migrations/20251007021253_Settings_Migrate_InternalDownloader2.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
485
server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.Designer.cs
generated
Normal file
485
server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ namespace RdtClient.Data.Migrations
|
|||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.5");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
|
|
@ -263,7 +263,8 @@ namespace RdtClient.Data.Migrations
|
|||
|
||||
b.HasKey("DownloadId");
|
||||
|
||||
b.HasIndex("TorrentId");
|
||||
b.HasIndex("TorrentId", "Path")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Downloads");
|
||||
});
|
||||
|
|
@ -402,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");
|
||||
|
|
|
|||
|
|
@ -44,17 +44,19 @@ public class Download
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to create <see cref="Download"/>s
|
||||
/// 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.
|
||||
/// 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
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ public class Setting
|
|||
public String SettingId { get; set; } = null!;
|
||||
|
||||
public String? Value { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +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 Int32 FinishedActionDelay { get; set; }
|
||||
public TorrentHostDownloadAction HostDownloadAction { get; set; }
|
||||
public Int32 DownloadMinSize { get; set; }
|
||||
public String? IncludeRegex { get; set; }
|
||||
|
|
@ -30,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; }
|
||||
|
||||
|
|
@ -58,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,4 +125,4 @@ public class Torrent
|
|||
return DownloadManualFiles.Split(",");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
namespace RdtClient.Data.Models.TorrentClient;
|
||||
using RdtClient.Data.Enums;
|
||||
|
||||
public class TorrentClientTorrent
|
||||
namespace RdtClient.Data.Models.DebridClient;
|
||||
|
||||
public class DebridClientTorrent
|
||||
{
|
||||
public String Id { get; set; } = default!;
|
||||
public String Filename { get; set; } = default!;
|
||||
|
|
@ -15,9 +17,11 @@ public class TorrentClientTorrent
|
|||
public String? Message { get; set; }
|
||||
public Int64 StatusCode { get; set; }
|
||||
public DateTimeOffset? Added { get; set; }
|
||||
public List<TorrentClientFile>? Files { get; set; }
|
||||
|
||||
public DownloadType Type { get; set; } = DownloadType.Torrent;
|
||||
public List<DebridClientFile>? Files { get; set; }
|
||||
public List<String>? Links { get; set; }
|
||||
public DateTimeOffset? Ended { get; set; }
|
||||
public Int64? Speed { get; set; }
|
||||
public Int64? Seeders { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
namespace RdtClient.Data.Models.TorrentClient;
|
||||
namespace RdtClient.Data.Models.DebridClient;
|
||||
|
||||
public class TorrentClientUser
|
||||
public class DebridClientUser
|
||||
{
|
||||
public String? Username { get; set; }
|
||||
public DateTimeOffset? Expiration { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ public class AppSettings
|
|||
{
|
||||
public AppSettingsLogging? Logging { get; set; }
|
||||
public AppSettingsDatabase? Database { get; set; }
|
||||
|
||||
|
||||
public Int32 Port { get; set; }
|
||||
public String? BasePath { get; set; }
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ public class AppSettingsLogging
|
|||
{
|
||||
public AppSettingsLoggingFile? File { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class AppSettingsLoggingFile
|
||||
{
|
||||
public String? Path { get; set; }
|
||||
|
|
@ -24,4 +24,4 @@ public class AppSettingsLoggingFile
|
|||
public class AppSettingsDatabase
|
||||
{
|
||||
public String? Path { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using RdtClient.Data.Enums;
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using RdtClient.Data.Enums;
|
||||
|
||||
// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global
|
||||
|
||||
|
|
@ -83,6 +84,10 @@ Supports the following parameters:
|
|||
[Description("The time in minutes to cache the tracker list. Set to 0 to disable caching.")]
|
||||
public Int32 TrackerEnrichmentCacheExpiration { get; set; } = 60;
|
||||
|
||||
[DisplayName("Banned Trackers")]
|
||||
[Description("Torrents that come from these trackers will not be allowed, this is a failsafe if you are accidentally downloading from private trackers. Will compare by keyword. Define multiple trackers by separating them with a comma.")]
|
||||
public String? BannedTrackers { get; set; } = null;
|
||||
|
||||
[DisplayName("Disable update notifications")]
|
||||
[Description("Ignore update notifications. You will still be notified if the version you are running has a security vulnerability.")]
|
||||
public Boolean DisableUpdateNotifications { get; set; } = false;
|
||||
|
|
@ -93,7 +98,7 @@ public class DbSettingsDownloadClient
|
|||
[DisplayName("Download client")]
|
||||
[Description(@"Select which download client to use, see the
|
||||
<a href=""https://github.com/rogerfar/rdt-client/"" target=""_blank"">README</a> for the various options.")]
|
||||
public DownloadClient Client { get; set; } = DownloadClient.Internal;
|
||||
public DownloadClient Client { get; set; } = DownloadClient.Bezzad;
|
||||
|
||||
[DisplayName("Download path")]
|
||||
[Description("Path in the docker container to download files to (i.e. /data/downloads), or a local path when using as a service.")]
|
||||
|
|
@ -151,6 +156,7 @@ http://127.0.0.1:6800/jsonrpc.")]
|
|||
[DisplayName("Synology DownloadStation Username")]
|
||||
[Description("The username to use when connecting to the Synology DownloadStation.")]
|
||||
public String? DownloadStationUsername { get; set; } = null;
|
||||
|
||||
[DisplayName("Synology DownloadStation Password")]
|
||||
[Description("The password to use when connecting to the Synology DownloadStation.")]
|
||||
public String? DownloadStationPassword { get; set; } = null;
|
||||
|
|
@ -159,6 +165,14 @@ http://127.0.0.1:6800/jsonrpc.")]
|
|||
[Description("The root path to doawnload the file on the Synology DownloadStation host, if empty use the default DownloadStation path.")]
|
||||
public String? DownloadStationDownloadPath { get; set; } = null;
|
||||
|
||||
[DisplayName("Minimum free disk space (GB) (Bezzad only)")]
|
||||
[Description("Minimum free disk space in GB. Bezzad downloads will pause when space falls below this value and will resume only when double this space is available. Set to 0 to disable.")]
|
||||
public Int32 MinimumFreeSpaceGB { get; set; } = 0;
|
||||
|
||||
[DisplayName("Disk space check interval (minutes)")]
|
||||
[Description("How often to check available disk space, in minutes.")]
|
||||
public Int32 DiskSpaceCheckIntervalMinutes { get; set; } = 5;
|
||||
|
||||
[DisplayName("Log level")]
|
||||
[Description("Only set when trying to debug a download client, can generate a lot of logs.")]
|
||||
public DownloadClientLogLevel LogLevel { get; set; } = DownloadClientLogLevel.None;
|
||||
|
|
@ -190,7 +204,7 @@ or
|
|||
public String ApiKey { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// API hostname to use <b>for Real Debrid only</b>
|
||||
/// API hostname to use <b>for Real Debrid only</b>
|
||||
/// </summary>
|
||||
[DisplayName("API Hostname (RD only)")]
|
||||
[Description("Use this instead of the normal hostname for Real Debrid API requests. Only used by Real Debrid. Leave blank to use default.")]
|
||||
|
|
@ -250,6 +264,7 @@ public class DbSettingsWatch
|
|||
|
||||
[DisplayName("Check Interval")]
|
||||
[Description("Time in seconds to check the folder for new files.")]
|
||||
[Range(10, Int32.MaxValue, ErrorMessage = "Interval must be at least 10 seconds.")]
|
||||
public Int32 Interval { get; set; } = 60;
|
||||
|
||||
[DisplayName("Import Defaults")]
|
||||
|
|
@ -269,7 +284,7 @@ public class DbSettingsDefaultsWithCategory : DbSettingsDefaults
|
|||
[DisplayName("Post Download Action")]
|
||||
[Description("When all files are downloaded from the provider to the host, perform this action. Does not apply when using the symlink downloader.")]
|
||||
public TorrentFinishedAction FinishedAction { get; set; } = TorrentFinishedAction.RemoveAllTorrents;
|
||||
|
||||
|
||||
[DisplayName("Finished Action Delay")]
|
||||
[Description("When all files are downloaded from the provider to the host, wait this many minutes before performing the action above.")]
|
||||
public Int32 FinishedActionDelay { get; set; } = 0;
|
||||
|
|
@ -312,4 +327,4 @@ public class DbSettingsDefaults
|
|||
[DisplayName("Priority")]
|
||||
[Description("Set the priority of a torrent, 1 = highest, 0 = disabled.")]
|
||||
public Int32 Priority { get; set; } = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
server/RdtClient.Data/Models/Internal/DiskSpaceStatus.cs
Normal file
9
server/RdtClient.Data/Models/Internal/DiskSpaceStatus.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace RdtClient.Data.Models.Internal;
|
||||
|
||||
public class DiskSpaceStatus
|
||||
{
|
||||
public Boolean IsPaused { get; set; }
|
||||
public Int64 AvailableSpaceGB { get; set; }
|
||||
public Int32 ThresholdGB { get; set; }
|
||||
public DateTimeOffset LastCheckTime { get; set; }
|
||||
}
|
||||
|
|
@ -9,4 +9,4 @@ public class Profile
|
|||
public String? LatestVersion { get; set; }
|
||||
public Boolean? IsInsecure { get; set; }
|
||||
public Boolean? DisableUpdateNotification { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace RdtClient.Data.Models.Internal;
|
||||
|
||||
public class RateLimitException(String message, TimeSpan retryAfter) : Exception(message)
|
||||
{
|
||||
public TimeSpan RetryAfter { get; } = retryAfter;
|
||||
}
|
||||
7
server/RdtClient.Data/Models/Internal/RateLimitStatus.cs
Normal file
7
server/RdtClient.Data/Models/Internal/RateLimitStatus.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace RdtClient.Data.Models.Internal;
|
||||
|
||||
public class RateLimitStatus
|
||||
{
|
||||
public DateTimeOffset? NextDequeueTime { get; set; }
|
||||
public Double SecondsRemaining { get; set; }
|
||||
}
|
||||
|
|
@ -8,4 +8,4 @@ public class SettingProperty
|
|||
public String? Description { get; set; }
|
||||
public String Type { get; set; } = default!;
|
||||
public Dictionary<Int32, String>? EnumValues { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
server/RdtClient.Data/Models/Internal/TorrentDto.cs
Normal file
71
server/RdtClient.Data/Models/Internal/TorrentDto.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using RdtClient.Data.Enums;
|
||||
using RdtClient.Data.Models.DebridClient;
|
||||
|
||||
namespace RdtClient.Data.Models.Internal;
|
||||
|
||||
public class TorrentDto
|
||||
{
|
||||
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; }
|
||||
public String? ExcludeRegex { get; set; }
|
||||
public String? DownloadManualFiles { get; set; }
|
||||
public DownloadClient DownloadClient { get; set; }
|
||||
public DateTimeOffset Added { get; set; }
|
||||
public DateTimeOffset? FilesSelected { get; set; }
|
||||
public DateTimeOffset? Completed { get; set; }
|
||||
public DownloadType Type { get; set; }
|
||||
public String? FileOrMagnet { get; set; }
|
||||
public Boolean IsFile { get; set; }
|
||||
public Int32? Priority { get; set; }
|
||||
public Int32 RetryCount { get; set; }
|
||||
public Int32 DownloadRetryAttempts { get; set; }
|
||||
public Int32 TorrentRetryAttempts { get; set; }
|
||||
public Int32 DeleteOnError { get; set; }
|
||||
public Int32 Lifetime { get; set; }
|
||||
public String? Error { get; set; }
|
||||
public String? RdId { get; set; }
|
||||
public String? RdName { get; set; }
|
||||
public Int64? RdSize { get; set; }
|
||||
public String? RdHost { get; set; }
|
||||
public Int64? RdSplit { get; set; }
|
||||
public Int64? RdProgress { get; set; }
|
||||
public TorrentStatus? RdStatus { get; set; }
|
||||
public String? RdStatusRaw { get; set; }
|
||||
public DateTimeOffset? RdAdded { get; set; }
|
||||
public DateTimeOffset? RdEnded { get; set; }
|
||||
public Int64? RdSpeed { get; set; }
|
||||
public Int64? RdSeeders { get; set; }
|
||||
public String StatusText { get; set; } = null!;
|
||||
public Int32 FilesCount { get; set; }
|
||||
public Int32 DownloadsCount { get; set; }
|
||||
public IList<DebridClientFile> Files { get; set; } = [];
|
||||
public IList<DownloadDto> Downloads { get; set; } = [];
|
||||
}
|
||||
|
||||
public class DownloadDto
|
||||
{
|
||||
public Guid DownloadId { get; set; }
|
||||
public Guid TorrentId { get; set; }
|
||||
public String Path { get; set; } = null!;
|
||||
public String? Link { get; set; }
|
||||
public DateTimeOffset Added { get; set; }
|
||||
public DateTimeOffset? DownloadQueued { get; set; }
|
||||
public DateTimeOffset? DownloadStarted { get; set; }
|
||||
public DateTimeOffset? DownloadFinished { get; set; }
|
||||
public DateTimeOffset? UnpackingQueued { get; set; }
|
||||
public DateTimeOffset? UnpackingStarted { get; set; }
|
||||
public DateTimeOffset? UnpackingFinished { get; set; }
|
||||
public DateTimeOffset? Completed { get; set; }
|
||||
public Int32 RetryCount { get; set; }
|
||||
public String? Error { get; set; }
|
||||
public Int64 BytesTotal { get; set; }
|
||||
public Int64 BytesDone { get; set; }
|
||||
public Int64 Speed { get; set; }
|
||||
}
|
||||
|
|
@ -21,4 +21,4 @@ public class AppBuildInfo
|
|||
|
||||
[JsonPropertyName("zlib")]
|
||||
public String? Zlib { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -427,4 +427,4 @@ public class AppPreferences
|
|||
|
||||
public class ScanDirs
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,4 +99,4 @@ public class SyncMetaDataServerState
|
|||
|
||||
[JsonPropertyName("write_cache_overload")]
|
||||
public String? WriteCacheOverload { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ public class TorrentCategory
|
|||
|
||||
[JsonPropertyName("savePath")]
|
||||
public String? SavePath { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,21 @@ namespace RdtClient.Data.Models.QBittorrent;
|
|||
|
||||
public class TorrentFileItem
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public Int32 Index { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public String? Name { get; set; }
|
||||
}
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public Int64 Size { get; set; }
|
||||
|
||||
[JsonPropertyName("progress")]
|
||||
public Single Progress { get; set; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public Int32 Priority { get; set; }
|
||||
|
||||
[JsonPropertyName("is_seed")]
|
||||
public Boolean IsSeed { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public class TorrentInfo
|
|||
|
||||
[JsonPropertyName("completion_on")]
|
||||
public Int64? CompletionOn { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("content_path")]
|
||||
public String? ContentPath { get; set; }
|
||||
|
||||
|
|
@ -135,4 +135,4 @@ public class TorrentInfo
|
|||
|
||||
[JsonPropertyName("upspeed")]
|
||||
public Int64? Upspeed { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue