Compare commits

..

No commits in common. "main" and "v2.0.119" have entirely different histories.

200 changed files with 5869 additions and 15140 deletions

View file

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

View file

@ -1,51 +0,0 @@
{
"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"
}
}
}
}

View file

@ -1,16 +0,0 @@
#!/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

View file

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

View file

@ -33,7 +33,7 @@ jobs:
runs-on: ${{ matrix.config.runs-on }} runs-on: ${{ matrix.config.runs-on }}
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Extract version and patch .csproj - name: Extract version and patch .csproj
run: | run: |
@ -127,7 +127,7 @@ jobs:
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}@${{ needs.build.outputs.digest-arm64 }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.APP_NAME }}@${{ needs.build.outputs.digest-arm64 }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Docker Hub Registry Description - name: Docker Hub Registry Description
if: ${{ env.ENABLE_DOCKERHUB == 1 }} if: ${{ env.ENABLE_DOCKERHUB == 1 }}

View file

@ -16,23 +16,23 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: "lts/*" node-version: "lts/*"
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v3
with: with:
dotnet-version: '10' dotnet-version: '9'
- name: Install Frontend Dependencies - name: Install Frontend Dependencies
working-directory: client working-directory: client
run: npm install run: npm ci
- name: Build Frontend - name: Build Frontend
working-directory: client working-directory: client
@ -67,7 +67,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0

View file

@ -11,11 +11,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 10.0.x dotnet-version: 9.0.x
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore server run: dotnet restore server
- name: Build - name: Build

1
.gitignore vendored
View file

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

View file

@ -6,110 +6,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased ## [Unreleased
## [2.0.138] - 2026-06-05
### Fixed
- SABnzbd fixes.
## [2.0.137] - 2026-06-05
### Fixed
- Fix torbox save path mapping for single file downloads.
## [2.0.136] - 2026-05-30
### Added
- Added devcontainer development workflow.
### Changed
- Upgraded torbox.net dependency.
## [2.0.135] - 2026-05-27
### Changed
- When adding a torrent through the qBittorrent endpoints, it will wait to see if the torrent gets added properly or errors out, resulting in a better experience for infringing files and Sonarr / Radarr.
- Upgraded torbox.net dependency.
- Support SABnzbd API key auth, thanks to @ALenfant!
## [2.0.134] - 2026-05-22
### 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 ## [2.0.119] - 2025-10-13
### Removed ### Removed
- Removed internal downloader from the GUI. - Removed internal downloader from the GUI.

View file

@ -1,5 +1,5 @@
# Stage 1 - Build the frontend # Stage 1 - Build the frontend
FROM node:lts-alpine AS node-build-env FROM node:22.16.0-alpine3.22 AS node-build-env
ARG TARGETPLATFORM ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG BUILDPLATFORM ARG BUILDPLATFORM
@ -15,13 +15,13 @@ COPY root ./root
RUN \ RUN \
cd client && \ cd client && \
echo "**** Building Code ****" && \ echo "**** Building Code ****" && \
npm install && \ npm ci && \
npx ng build --output-path=out npx ng build --output-path=out
RUN ls -FCla /appclient/root RUN ls -FCla /appclient/root
# Stage 2 - Build the backend # Stage 2 - Build the backend
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS dotnet-build-env FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS dotnet-build-env
ARG TARGETPLATFORM ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG BUILDPLATFORM ARG BUILDPLATFORM
@ -67,14 +67,14 @@ RUN \
RUN \ RUN \
if [ "$TARGETPLATFORM" = "linux/arm/v7" ] ; then \ if [ "$TARGETPLATFORM" = "linux/arm/v7" ] ; then \
wget https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.0/aspnetcore-runtime-10.0.0-linux-musl-arm.tar.gz && \ 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-10.0.0-linux-musl-arm.tar.gz -C /usr/share/dotnet ; \ tar zxf aspnetcore-runtime-9.0.0-linux-musl-arm.tar.gz -C /usr/share/dotnet ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \ elif [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \
wget https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.0/aspnetcore-runtime-10.0.0-linux-musl-arm64.tar.gz && \ 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-10.0.0-linux-musl-arm64.tar.gz -C /usr/share/dotnet ; \ tar zxf aspnetcore-runtime-9.0.0-linux-musl-arm64.tar.gz -C /usr/share/dotnet ; \
else \ else \
wget https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.0/aspnetcore-runtime-10.0.0-linux-musl-x64.tar.gz && \ 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-10.0.0-linux-musl-x64.tar.gz -C /usr/share/dotnet ; \ tar zxf aspnetcore-runtime-9.0.0-linux-musl-x64.tar.gz -C /usr/share/dotnet ; \
fi fi
RUN \ RUN \

View file

@ -1,15 +1,14 @@
# Real-Debrid Torrent & Usenet Client # Real-Debrid Torrent 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 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 - Download all files from Real-Debrid, AllDebrid, Premiumize or TorBox to your local machine automatically
- Unpack all files when finished downloading - Unpack all files when finished downloading
- Implements a fake qBittorrent API so you can hook up other applications like Sonarr, Radarr or Couchpotato. - Implements a fake qBittorrent API so you can hook up other applications like Sonarr, Radarr or Couchpotato.
- Built with Angular 21 and .NET 10 - Built with Angular 15 and .NET 9
**You will need a Premium service at Real-Debrid, AllDebrid, Premiumize, Torbox or DebridLink!** **You will need a Premium service at Real-Debrid, AllDebrid, Premiumize or Torbox!**
[Click here to sign up for Real-Debrid.](https://real-debrid.com/?id=1348683) [Click here to sign up for Real-Debrid.](https://real-debrid.com/?id=1348683)
@ -35,7 +34,7 @@ Instead of running in Docker you can install it as a service in Windows or Linux
## Windows Service ## Windows Service
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) 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)
2. Get the latest zip file from the Releases page and extract it to your host. 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. 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. 4. In `appsettings.json` replace the `Database` `Path` to a path on your host.
@ -57,7 +56,7 @@ Instead of running in Docker you can install it as a service in Linux.
```rm packages-microsoft-prod.deb``` ```rm packages-microsoft-prod.deb```
```sudo apt-get update && sudo apt-get install -y dotnet-sdk-10.0``` ```sudo apt-get update && sudo apt-get install -y dotnet-sdk-9.0```
2. Get latest archive from [releases](https://github.com/rogerfar/rdt-client/releases): 2. Get latest archive from [releases](https://github.com/rogerfar/rdt-client/releases):
```wget <zip_url>``` ```wget <zip_url>```
@ -166,9 +165,7 @@ It has the following options:
### Connecting Sonarr/Radarr ### Connecting Sonarr/Radarr
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. 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.
#### Torrents
1. Login to Sonarr or Radarr and click `Settings`. 1. Login to Sonarr or Radarr and click `Settings`.
1. Go to the `Download Client` tab and click the plus to add. 1. Go to the `Download Client` tab and click the plus to add.
@ -185,24 +182,6 @@ 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. 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 ### 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. 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.
@ -214,24 +193,13 @@ By default the application runs in the root of your hosted address (i.e. https:/
- NodeJS - NodeJS
- NPM - NPM
- Angular CLI - Angular CLI
- .NET 10 - .NET 9
- Visual Studio 2025 - Visual Studio 2022
- (optional) Resharper - (optional) Resharper
### Dev Container
The repository includes a dev container under `.devcontainer/` for the split development workflow used by this project.
It installs .NET 10 and Node 22, forwards ports `4200` and `6500`, and persists `/data/db` and `/data/downloads` in named volumes so the local SQLite database, logs, and downloads survive container rebuilds.
1. Open the repository in the dev container.
1. In one terminal run `dotnet watch run --project server/RdtClient.Web`.
1. In another terminal run `cd client && npm start`.
1. Open `http://localhost:4200`. The Angular dev server proxies `/Api` and `/hub` to the backend running on `6500`.
1. Open the client folder project in VS Code and run `npm install`. 1. Open the client folder project in VS Code and run `npm install`.
1. To debug run `ng serve`, to build run `ng build -c production`. 1. To debug run `ng serve`, to build run `ng build -c production`.
1. Open the Visual Studio 2025 project `RdtClient.sln` and `Publish` the `RdtClient.Web` to the given `PublishFolder` target. 1. Open the Visual Studio 2019 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. When debugging, make sure to run `RdtClient.Web.dll` and not `IISExpress`.
1. The result is found in `Publish`. 1. The result is found in `Publish`.

View file

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

View file

@ -50,12 +50,7 @@
"assets": ["src/favicon.ico", "src/assets"], "assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],
"scripts": [], "scripts": [],
"browser": "src/main.ts", "browser": "src/main.ts"
"stylePreprocessorOptions": {
"sass": {
"silenceDeprecations": ["if-function"]
}
}
}, },
"configurations": { "configurations": {
"production": { "production": {

6887
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,24 +1,5 @@
<div class="field"> <div class="field">
<div class="tabs is-toggle is-fullwidth"> <div>
<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"> <div class="field">
<label class="label">Torrent file</label> <label class="label">Torrent file</label>
<div class="file has-name"> <div class="file has-name">
@ -50,53 +31,15 @@
></textarea> ></textarea>
</div> </div>
</div> </div>
</div>
<div [hidden]="type !== 'nzb'">
<div class="field">
<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"> <div class="field">
<label class="label">NZB Link</label>
<div class="control"> <div class="control">
<textarea <button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
class="textarea" <span>Add Torrent</span>
placeholder="Paste your NZB link here" </button>
[(ngModel)]="nzbLink"
[disabled]="saving"
></textarea>
</div> </div>
</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="separator">Advanced settings</div>
<div class="field"> <div class="field">
@ -109,7 +52,9 @@
<option [ngValue]="3">Synology DownloadStation</option> <option [ngValue]="3">Synology DownloadStation</option>
</select> </select>
</div> </div>
<p class="help">Select which downloader is used to download this from the debrid provider to your host.</p> <p class="help">
Select which downloader is used to download this torrent from the debrid provider to your host.
</p>
</div> </div>
<div class="field"> <div class="field">
@ -121,8 +66,8 @@
</select> </select>
</div> </div>
<p class="help"> <p class="help">
When a download is finished on the provider, perform this action. Use this setting if you only want to add files When a torrent is finished downloading on the provider, perform this action. Use this setting if you only want
to your debrid provider but not download them to the host. to add files to your debrid provider but not download them to the host.
</p> </p>
</div> </div>
@ -197,10 +142,10 @@
<select [(ngModel)]="finishedAction"> <select [(ngModel)]="finishedAction">
<option [ngValue]="0">Do nothing</option> <option [ngValue]="0">Do nothing</option>
@if (downloadClient !== 2) { @if (downloadClient !== 2) {
<option [ngValue]="1">Remove from provider and client</option> <option [ngValue]="1">Remove torrent from provider and client</option>
<option [ngValue]="2">Remove from provider</option> <option [ngValue]="2">Remove torrent from provider</option>
} }
<option [ngValue]="3">Remove from client</option> <option [ngValue]="3">Remove torrent from client</option>
</select> </select>
</div> </div>
</div> </div>
@ -211,31 +156,15 @@
<input class="input" type="number" [(ngModel)]="finishedActionDelay" /> <input class="input" type="number" [(ngModel)]="finishedActionDelay" />
</div> </div>
<p class="help"> <p class="help">
When a download is finished on the provider, perform this action. Use this setting if you only want to add files When a torrent is finished downloading on the provider, perform this action. Use this setting if you only want
to your debrid provider but not download them to the host. to add files to your debrid provider but not download them to the host.
</p> </p>
</div> </div>
<div class="field"> <div class="field">
<label class="label">Category</label> <label class="label">Category</label>
<div class="control category-combo"> <div class="control">
<input <input class="input" type="text" [(ngModel)]="category" />
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> </div>
<p class="help">The category becomes a sub-folder in your main download path.</p> <p class="help">The category becomes a sub-folder in your main download path.</p>
</div> </div>
@ -245,13 +174,10 @@
<div class="control"> <div class="control">
<input class="input" type="number" step="1" [(ngModel)]="priority" /> <input class="input" type="number" step="1" [(ngModel)]="priority" />
</div> </div>
<p class="help">Set the priority where 1 is the highest. When empty it will be assigned the lowest priority.</p> <p class="help">
Set the priority for this torrent where 1 is the highest. When empty it will be assigned the lowest priority.
</p>
</div> </div>
</div>
</div>
<div class="field">
<div>
<div class="field"> <div class="field">
<label class="label">Automatic retry downloads</label> <label class="label">Automatic retry downloads</label>
<div class="control"> <div class="control">
@ -260,13 +186,13 @@
<p class="help">When a single download fails it will retry it this many times before marking it as failed.</p> <p class="help">When a single download fails it will retry it this many times before marking it as failed.</p>
</div> </div>
<div class="field"> <div class="field">
<label class="label">Automatic retry download</label> <label class="label">Automatic retry torrent</label>
<div class="control"> <div class="control">
<input class="input" type="number" max="1000" min="0" step="1" [(ngModel)]="torrentRetryAttempts" /> <input class="input" type="number" max="1000" min="0" step="1" [(ngModel)]="torrentRetryAttempts" />
</div> </div>
<p class="help"> <p class="help">
When a single download has failed multiple times (see setting above) or when the download itself received an When a single download has failed multiple times (see setting above) or when the torrent itself received an
error it will retry the full download this many times before marking it failed. error it will retry the full torrent this many times before marking it failed.
</p> </p>
</div> </div>
<div class="field"> <div class="field">
@ -280,13 +206,13 @@
</p> </p>
</div> </div>
<div class="field"> <div class="field">
<label class="label">Maximum lifetime</label> <label class="label">Torrent maximum lifetime</label>
<div class="control"> <div class="control">
<input class="input" type="number" max="100000" min="0" step="1" [(ngModel)]="torrentLifetime" /> <input class="input" type="number" max="100000" min="0" step="1" [(ngModel)]="torrentLifetime" />
</div> </div>
<p class="help"> <p class="help">
The maximum lifetime of a download in minutes. When this time has passed, mark it as error. If the download is The maximum lifetime of a torrent in minutes. When this time has passed, mark the torrent as error. If the
completed, the lifetime setting will not apply. 0 to disable. torrent is completed and has downloads, the lifetime setting will not apply. 0 to disable.
</p> </p>
</div> </div>
</div> </div>
@ -299,3 +225,11 @@
} }
</div> </div>
</div> </div>
<div class="field">
<div class="control">
<button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
<span>Add Torrent</span>
</button>
</div>
</div>

View file

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

View file

@ -1,12 +1,11 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DownloadType, Torrent, TorrentFileAvailability } from '../models/torrent.model'; import { TorrentService } from 'src/app/torrent.service';
import { Torrent, TorrentFileAvailability } from '../models/torrent.model';
import { SettingsService } from '../settings.service'; import { SettingsService } from '../settings.service';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { TorrentService } from '../torrent.service';
@Component({ @Component({
selector: 'app-add-new-torrent', selector: 'app-add-new-torrent',
@ -16,25 +15,14 @@ import { TorrentService } from '../torrent.service';
standalone: true, standalone: true,
}) })
export class AddNewTorrentComponent implements OnInit { 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 fileName: string;
public magnetLink: string; public magnetLink: string;
public nzbLink: string;
private currentTorrentFile: string; private currentTorrentFile: string;
public provider: string; public provider: string;
public downloadClient: number; public downloadClient: number;
private _category = ''; public category: string;
public categories: string[] = [];
public filteredCategories: string[] = [];
public categoryDropdownOpen = false;
public hostDownloadAction: number = 0; public hostDownloadAction: number = 0;
public downloadAction: number = 0; public downloadAction: number = 0;
public finishedAction: number = 0; public finishedAction: number = 0;
@ -59,93 +47,44 @@ export class AddNewTorrentComponent implements OnInit {
public excludeRegexError: string; public excludeRegexError: string;
public regexSelected: TorrentFileAvailability[]; public regexSelected: TorrentFileAvailability[];
private selectedFile: File | null = null; private selectedFile: File;
public get category(): string { constructor(
return this._category; private router: Router,
} private torrentService: TorrentService,
private settingsService: SettingsService,
public set category(value: string) { private activatedRoute: ActivatedRoute,
this._category = value; ) {}
this.updateFilteredCategories();
}
ngOnInit(): void { ngOnInit(): void {
this.activatedRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { this.activatedRoute.queryParams.subscribe((params) => {
if (params['type'] === 'nzb') {
this.type = 'nzb';
} else if (params['type'] === 'torrent') {
this.type = 'torrent';
}
if (params['magnet']) { if (params['magnet']) {
this.magnetLink = decodeURIComponent(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.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.settingsService this.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string;
.get() this.hostDownloadAction = this.downloadAction = settings.find((m) => m.key === 'Gui:Default:HostDownloadAction')
.pipe(takeUntilDestroyed(this.destroyRef)) ?.value as number;
.subscribe((settings) => { this.downloadAction =
const providerSetting = settings.find((m) => m.key === 'Provider:Provider'); settings.find((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.provider = providerSetting.enumValues[providerSetting.value as number]; this.finishedAction = settings.find((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.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.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string; this.setFinishAction();
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() { public setFinishAction() {
@ -158,16 +97,10 @@ 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 { public pickFile(evt: Event): void {
const files = (evt.target as HTMLInputElement).files; const files = (evt.target as HTMLInputElement).files;
if (files == null || files.length === 0) { if (files.length === 0) {
return; return;
} }
@ -219,49 +152,25 @@ export class AddNewTorrentComponent implements OnInit {
torrent.lifetime = this.torrentLifetime; torrent.lifetime = this.torrentLifetime;
torrent.downloadClient = this.downloadClient; torrent.downloadClient = this.downloadClient;
if (this.type === 'torrent') { if (this.magnetLink) {
torrent.type = DownloadType.Torrent; this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe({
if (this.magnetLink) { next: () => this.router.navigate(['/']),
this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe({ error: (err) => {
next: () => this.router.navigate(['/']), this.error = err.error;
error: (err) => { this.saving = false;
this.error = err.error; },
this.saving = false; });
}, } else if (this.selectedFile) {
}); this.torrentService.uploadFile(this.selectedFile, torrent).subscribe({
} else if (this.selectedFile) { next: () => this.router.navigate(['/']),
this.torrentService.uploadFile(this.selectedFile, torrent).subscribe({ error: (err) => {
next: () => this.router.navigate(['/']), this.error = err.error;
error: (err) => { this.saving = false;
this.error = err.error; },
this.saving = false; });
},
});
} else {
this.error = 'No magnet or file uploaded';
this.saving = false;
}
} else { } else {
if (this.nzbLink) { this.error = 'No magnet or file uploaded';
this.torrentService.uploadNzbLink(this.nzbLink, torrent).subscribe({ this.saving = false;
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;
}
} }
} }
@ -272,9 +181,6 @@ export class AddNewTorrentComponent implements OnInit {
} }
public checkFiles(): void { public checkFiles(): void {
if (this.type === 'nzb') {
return;
}
if (this.magnetLink && this.magnetLink === this.currentTorrentFile) { if (this.magnetLink && this.magnetLink === this.currentTorrentFile) {
return; return;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,7 +28,6 @@ export class Torrent {
public lifetime: number; public lifetime: number;
public priority: number; public priority: number;
public type: DownloadType;
public error: string; public error: string;
public rdId: string; public rdId: string;
@ -44,12 +43,9 @@ export class Torrent {
public rdSpeed: number; public rdSpeed: number;
public rdSeeders: number; public rdSeeders: number;
public rdFiles: string; public rdFiles: string;
public statusText?: string;
public filesCount?: number;
public downloadsCount?: number;
public files?: TorrentFile[]; public files: TorrentFile[];
public downloads?: Download[]; public downloads: Download[];
} }
export class TorrentFile { export class TorrentFile {
@ -77,8 +73,3 @@ export enum RealDebridStatus {
Error = 99, Error = 99,
} }
export enum DownloadType {
Torrent = 0,
Nzb = 1,
}

View file

@ -24,13 +24,13 @@
<span class="icon"> <span class="icon">
<i class="fas fa-table" aria-hidden="true"></i> <i class="fas fa-table" aria-hidden="true"></i>
</span> </span>
<span>Torrents/NZBs</span> <span>Torrents</span>
</a> </a>
<a class="navbar-item" routerLink="/add" [queryParams]="{ type: 'torrent' }"> <a class="navbar-item" routerLink="/add">
<span class="icon"> <span class="icon">
<i class="fas fa-plus" aria-hidden="true"></i> <i class="fas fa-plus" aria-hidden="true"></i>
</span> </span>
<span>Add Torrent/NZB</span> <span>Add New Torrent</span>
</a> </a>
<a class="navbar-item" routerLink="/settings"> <a class="navbar-item" routerLink="/settings">
<span class="icon"> <span class="icon">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,78 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
export type SortDirection = 'asc' | 'desc'; @Pipe({ name: 'sort', })
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;
}
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 { export class SortPipe implements PipeTransform {
transform(array: unknown[], field: string, order: SortDirection = 'asc'): unknown[] { transform(array: any[], field: string, order: 'asc' | 'desc' = 'asc'): any[] {
return sortItems(array, field, order); if (!Array.isArray(array)) {
return [];
}
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();
} }
} }

View file

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

View file

@ -4,121 +4,69 @@
Please refresh the screen after fixing this error. Please refresh the screen after fixing this error.
</div> </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"> <div class="table-container">
@if (!isMobile) { <table class="table is-fullwidth is-hoverable">
<table class="table is-fullwidth is-hoverable"> <thead>
<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) {
<tr> <tr>
<th> <td>
<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 <input
type="checkbox" type="checkbox"
(click)="toggleSelect(torrent.torrentId)" (click)="toggleSelect(torrent.torrentId)"
[checked]="isSelected(torrent.torrentId)" [checked]="selectedTorrents.includes(torrent.torrentId)"
/> />
</div> </td>
<div class="mobile-card-body" (click)="openTorrent(torrent.torrentId)"> <td (click)="openTorrent(torrent.torrentId)" class="break-all">
<p class="mobile-card-name">{{ torrent.rdName }}</p> {{ torrent.rdName }}
<div class="tags mb-2"> </td>
<span class="tag is-info is-light">{{ torrent.statusText ?? (torrent | status) }}</span> <td>
@if (torrent.category) { {{ torrent.category }}
<span class="tag is-light">{{ torrent.category }}</span> </td>
} <td>
</div> {{ torrent.priority }}
<div class="mobile-card-details"> </td>
<span class="mobile-card-detail" <td>
><span class="detail-label">Size</span> {{ torrent.rdSize | filesize }}</span {{ torrent.rdSeeders }}
> </td>
<span class="mobile-card-detail"><span class="detail-label">Seeders</span> {{ torrent.rdSeeders }}</span> <td>
<span class="mobile-card-detail" {{ torrent.files.length | number }}
><span class="detail-label">Files</span> {{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }}</span </td>
> <td>
<span class="mobile-card-detail" {{ torrent.downloads.length | number }}
><span class="detail-label">Downloads</span> {{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}</span </td>
> <td>
</div> {{ torrent.rdSize | filesize }}
</div> </td>
</div> <td>
{{ torrent.added | date: "medium" }}
</td>
<td>
{{ torrent | status }}
</td>
</tr>
} }
</div> </tbody>
} </table>
<div class="flex-container"> <div class="flex-container">
@if (torrents.length > 0) { @if (torrents.length > 0) {

View file

@ -7,90 +7,13 @@ 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 { .flex-container {
display: flex; display: flex;
flex: 1 1 0; flex: 1 1 0;
gap: 20px; gap: 20px;
flex-direction: row; flex-direction: row;
margin-top: 1rem;
@media screen and (max-width: 1279px) { @media screen and (max-width: 1279px) {
flex-direction: column; flex-direction: column;
} }
@media screen and (max-width: 768px) {
gap: 0.5rem;
.button {
width: 100%;
}
}
} }

View file

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

View file

@ -1,26 +1,22 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import * as signalR from '@microsoft/signalr'; import * as signalR from '@microsoft/signalr';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { Torrent, TorrentFileAvailability } from './models/torrent.model'; 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'; import { APP_BASE_HREF } from '@angular/common';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class TorrentService { export class TorrentService {
private http = inject(HttpClient);
private baseHref = inject(APP_BASE_HREF);
public update$: Subject<Torrent[]> = new Subject(); public update$: Subject<Torrent[]> = new Subject();
public diskSpaceStatus$: Subject<DiskSpaceStatus> = new Subject();
public rateLimitStatus$: Subject<RateLimitStatus> = new Subject();
private connection: signalR.HubConnection; private connection: signalR.HubConnection;
constructor() { constructor(
private http: HttpClient,
@Inject(APP_BASE_HREF) private baseHref: string,
) {
this.connect(); this.connect();
} }
@ -33,30 +29,11 @@ export class TorrentService {
.withUrl(`${this.baseHref}hub`) .withUrl(`${this.baseHref}hub`)
.withAutomaticReconnect() .withAutomaticReconnect()
.build(); .build();
this.connection.start().catch((err) => console.error(err));
this.connection.on('update', (torrents: Torrent[]) => { this.connection.on('update', (torrents: Torrent[]) => {
this.update$.next(torrents); 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[]> { public getList(): Observable<Torrent[]> {
@ -67,14 +44,6 @@ export class TorrentService {
return this.http.get<Torrent>(`${this.baseHref}Api/Torrents/Get/${torrentId}`); 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> { public uploadMagnet(magnetLink: string, torrent: Torrent): Observable<void> {
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadMagnet`, { return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadMagnet`, {
magnetLink, magnetLink,
@ -89,20 +58,6 @@ export class TorrentService {
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadFile`, formData); 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[]> { public checkFilesMagnet(magnetLink: string): Observable<TorrentFileAvailability[]> {
return this.http.post<TorrentFileAvailability[]>(`${this.baseHref}Api/Torrents/CheckFilesMagnet`, { return this.http.post<TorrentFileAvailability[]>(`${this.baseHref}Api/Torrents/CheckFilesMagnet`, {
magnetLink, magnetLink,

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,10 @@
"strictNullChecks": false, "strictNullChecks": false,
"target": "ES2022", "target": "ES2022",
"module": "es2020", "module": "es2020",
"lib": [
"es2020",
"dom"
],
"useDefineForClassFields": false "useDefineForClassFields": false
}, },
"angularCompilerOptions": { "angularCompilerOptions": {

View file

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

View file

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

View file

@ -1,60 +1,37 @@
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using Download = RdtClient.Data.Models.Data.Download; using Download = RdtClient.Data.Models.Data.Download;
namespace RdtClient.Data.Data; namespace RdtClient.Data.Data;
public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger = null) public class DownloadData(DataContext dataContext)
{ {
public async Task<List<Download>> GetForTorrent(Guid torrentId) public async Task<List<Download>> GetForTorrent(Guid torrentId)
{ {
return await dataContext.Downloads return await dataContext.Downloads
.AsNoTracking() .AsNoTracking()
.Where(m => m.TorrentId == torrentId) .Where(m => m.TorrentId == torrentId)
.ToListAsync(); .ToListAsync();
} }
public async Task<Download?> GetById(Guid downloadId) public async Task<Download?> GetById(Guid downloadId)
{ {
return await dataContext.Downloads return await dataContext.Downloads
.Include(m => m.Torrent) .Include(m => m.Torrent)
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
} }
public async Task<Download?> Get(Guid torrentId, String path) public async Task<Download?> Get(Guid torrentId, String path)
{ {
return await dataContext.Downloads return await dataContext.Downloads
.Include(m => m.Torrent) .Include(m => m.Torrent)
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(m => m.TorrentId == torrentId && m.Path == path); .FirstOrDefaultAsync(m => m.TorrentId == torrentId && m.Path == path);
} }
public async Task<DownloadAddResult> TryAddForTorrent(Guid torrentId, DownloadInfo downloadInfo) public async Task<Download> Add(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 var download = new Download
{ {
DownloadId = Guid.NewGuid(), DownloadId = Guid.NewGuid(),
@ -68,42 +45,17 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
await dataContext.Downloads.AddAsync(download); await dataContext.Downloads.AddAsync(download);
try await dataContext.SaveChangesAsync();
{
await dataContext.SaveChangesAsync();
return DownloadAddResult.Added; await TorrentData.VoidCache();
}
// These shouldn't be possible any longer, but added for safety and until confirmed. return download;
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) public async Task UpdateUnrestrictedLink(Guid downloadId, String unrestrictedLink)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -113,12 +65,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.Link = unrestrictedLink; dbDownload.Link = unrestrictedLink;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateFileName(Guid downloadId, String fileName) public async Task UpdateFileName(Guid downloadId, String fileName)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -128,12 +82,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.FileName = fileName; dbDownload.FileName = fileName;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime) public async Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -143,12 +99,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.DownloadStarted = dateTime; dbDownload.DownloadStarted = dateTime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateDownloadFinished(Guid downloadId, DateTimeOffset? dateTime) public async Task UpdateDownloadFinished(Guid downloadId, DateTimeOffset? dateTime)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -158,12 +116,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.DownloadFinished = dateTime; dbDownload.DownloadFinished = dateTime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateUnpackingQueued(Guid downloadId, DateTimeOffset? dateTime) public async Task UpdateUnpackingQueued(Guid downloadId, DateTimeOffset? dateTime)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -173,12 +133,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.UnpackingQueued = dateTime; dbDownload.UnpackingQueued = dateTime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateUnpackingStarted(Guid downloadId, DateTimeOffset? dateTime) public async Task UpdateUnpackingStarted(Guid downloadId, DateTimeOffset? dateTime)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -188,12 +150,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.UnpackingStarted = dateTime; dbDownload.UnpackingStarted = dateTime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateUnpackingFinished(Guid downloadId, DateTimeOffset? dateTime) public async Task UpdateUnpackingFinished(Guid downloadId, DateTimeOffset? dateTime)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -203,12 +167,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.UnpackingFinished = dateTime; dbDownload.UnpackingFinished = dateTime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateCompleted(Guid downloadId, DateTimeOffset? dateTime) public async Task UpdateCompleted(Guid downloadId, DateTimeOffset? dateTime)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -218,12 +184,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.Completed = dateTime; dbDownload.Completed = dateTime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateError(Guid downloadId, String? error) public async Task UpdateError(Guid downloadId, String? error)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -233,12 +201,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.Error = error; dbDownload.Error = error;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateRetryCount(Guid downloadId, Int32 retryCount) public async Task UpdateRetryCount(Guid downloadId, Int32 retryCount)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -248,12 +218,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.RetryCount = retryCount; dbDownload.RetryCount = retryCount;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task UpdateRemoteId(Guid downloadId, String remoteId) public async Task UpdateRemoteId(Guid downloadId, String remoteId)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId); .FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null) if (dbDownload == null)
{ {
@ -268,24 +240,26 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
public async Task DeleteForTorrent(Guid torrentId) public async Task DeleteForTorrent(Guid torrentId)
{ {
var downloads = await dataContext.Downloads var downloads = await dataContext.Downloads
.Where(m => m.TorrentId == torrentId) .Where(m => m.TorrentId == torrentId)
.ToListAsync(); .ToListAsync();
dataContext.Downloads.RemoveRange(downloads); dataContext.Downloads.RemoveRange(downloads);
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await TorrentData.VoidCache();
} }
public async Task Reset(Guid downloadId, DateTimeOffset? downloadQueued = null) public async Task Reset(Guid downloadId)
{ {
var dbDownload = await dataContext.Downloads var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId) .FirstOrDefaultAsync(m => m.DownloadId == downloadId)
?? throw new($"Cannot find download with ID {downloadId}"); ?? throw new($"Cannot find download with ID {downloadId}");
dbDownload.RetryCount = 0; dbDownload.RetryCount = 0;
dbDownload.Link = null; dbDownload.Link = null;
dbDownload.Added = DateTimeOffset.UtcNow; dbDownload.Added = DateTimeOffset.UtcNow;
dbDownload.DownloadQueued = downloadQueued ?? DateTimeOffset.UtcNow; dbDownload.DownloadQueued = DateTimeOffset.UtcNow;
dbDownload.DownloadStarted = null; dbDownload.DownloadStarted = null;
dbDownload.DownloadFinished = null; dbDownload.DownloadFinished = null;
dbDownload.UnpackingQueued = null; dbDownload.UnpackingQueued = null;
@ -295,21 +269,7 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.Error = null; dbDownload.Error = null;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
}
private static Boolean IsDuplicateDownloadViolation(DbUpdateException exception) await TorrentData.VoidCache();
{
var sqliteException = exception.InnerException as SqliteException;
return sqliteException?.SqliteExtendedErrorCode == 2067
|| sqliteException?.Message.Contains("UNIQUE constraint failed: Downloads.TorrentId, Downloads.Path", StringComparison.Ordinal) == true;
}
private static Boolean IsForeignKeyViolation(DbUpdateException exception)
{
var sqliteException = exception.InnerException as SqliteException;
return sqliteException?.SqliteExtendedErrorCode == 787
|| sqliteException?.Message.Contains("FOREIGN KEY constraint failed", StringComparison.Ordinal) == true;
} }
} }

View file

@ -13,13 +13,11 @@ public interface ITorrentData
String hash, String hash,
String? fileOrMagnetContents, String? fileOrMagnetContents,
Boolean isFile, Boolean isFile,
DownloadType downloadType,
DownloadClient downloadClient, DownloadClient downloadClient,
Torrent torrent); Torrent torrent);
Task UpdateRdData(Torrent torrent); Task UpdateRdData(Torrent torrent);
Task UpdateRdId(Torrent torrent, String rdId); Task UpdateRdId(Torrent torrent, String rdId);
Task UpdateHash(Torrent torrent, String hash);
Task Update(Torrent torrent); Task Update(Torrent torrent);
Task UpdateCategory(Guid torrentId, String? category); Task UpdateCategory(Guid torrentId, String? category);
Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry); Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry);

View file

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

View file

@ -1,32 +1,40 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
namespace RdtClient.Data.Data; namespace RdtClient.Data.Data;
public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger = null) : ITorrentData public class TorrentData(DataContext dataContext) : ITorrentData
{ {
private static IList<Torrent>? _torrentCache;
private static readonly SemaphoreSlim TorrentCacheLock = new(1, 1);
public async Task<IList<Torrent>> Get() public async Task<IList<Torrent>> Get()
{ {
var torrents = await dataContext.Torrents await TorrentCacheLock.WaitAsync();
.AsNoTracking()
.AsSplitQuery()
.Include(m => m.Downloads)
.OrderBy(m => m.Priority ?? 9999)
.ToListAsync();
return torrents.OrderBy(m => m.Priority ?? 9999) try
.ThenBy(m => m.Added) {
.ToList(); _torrentCache ??= await dataContext.Torrents
.AsNoTracking()
.Include(m => m.Downloads)
.ToListAsync();
return [.. _torrentCache.OrderBy(m => m.Priority ?? 9999).ThenBy(m => m.Added)];
}
finally
{
TorrentCacheLock.Release();
}
} }
public async Task<Torrent?> GetById(Guid torrentId) public async Task<Torrent?> GetById(Guid torrentId)
{ {
var dbTorrent = await dataContext.Torrents var dbTorrent = await dataContext.Torrents
.AsNoTracking() .AsNoTracking()
.Include(m => m.Downloads) .Include(m => m.Downloads)
.FirstOrDefaultAsync(m => m.TorrentId == torrentId); .FirstOrDefaultAsync(m => m.TorrentId == torrentId);
if (dbTorrent == null) if (dbTorrent == null)
{ {
@ -46,9 +54,9 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
hash = hash.ToLower(); hash = hash.ToLower();
var dbTorrent = await dataContext.Torrents var dbTorrent = await dataContext.Torrents
.AsNoTracking() .AsNoTracking()
.Include(m => m.Downloads) .Include(m => m.Downloads)
.FirstOrDefaultAsync(m => m.Hash == hash); .FirstOrDefaultAsync(m => m.Hash == hash);
if (dbTorrent == null) if (dbTorrent == null)
{ {
@ -67,7 +75,6 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
String hash, String hash,
String? fileOrMagnetContents, String? fileOrMagnetContents,
Boolean isFile, Boolean isFile,
DownloadType downloadType,
DownloadClient downloadClient, DownloadClient downloadClient,
Torrent torrent) Torrent torrent)
{ {
@ -87,7 +94,6 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
ExcludeRegex = torrent.ExcludeRegex, ExcludeRegex = torrent.ExcludeRegex,
DownloadManualFiles = torrent.DownloadManualFiles, DownloadManualFiles = torrent.DownloadManualFiles,
DownloadClient = downloadClient, DownloadClient = downloadClient,
Type = downloadType,
FileOrMagnet = fileOrMagnetContents, FileOrMagnet = fileOrMagnetContents,
IsFile = isFile, IsFile = isFile,
Priority = torrent.Priority, Priority = torrent.Priority,
@ -103,6 +109,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
return newTorrent; return newTorrent;
} }
@ -129,6 +137,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.RdFiles = torrent.RdFiles; dbTorrent.RdFiles = torrent.RdFiles;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
} }
public async Task UpdateRdId(Torrent torrent, String rdId) public async Task UpdateRdId(Torrent torrent, String rdId)
@ -143,20 +153,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.RdId = rdId; dbTorrent.RdId = rdId;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
}
public async Task UpdateHash(Torrent torrent, String hash) await VoidCache();
{
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) public async Task Update(Torrent torrent)
@ -178,6 +176,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.Lifetime = torrent.Lifetime; dbTorrent.Lifetime = torrent.Lifetime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
} }
public async Task UpdateCategory(Guid torrentId, String? category) public async Task UpdateCategory(Guid torrentId, String? category)
@ -192,6 +192,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.Category = category; dbTorrent.Category = category;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
} }
public async Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry) public async Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry)
@ -227,6 +229,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.Error = error; dbTorrent.Error = error;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
} }
public async Task UpdateFilesSelected(Guid torrentId, DateTimeOffset datetime) public async Task UpdateFilesSelected(Guid torrentId, DateTimeOffset datetime)
@ -241,6 +245,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.FilesSelected = datetime; dbTorrent.FilesSelected = datetime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
} }
public async Task UpdatePriority(Guid torrentId, Int32? priority) public async Task UpdatePriority(Guid torrentId, Int32? priority)
@ -255,6 +261,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.Priority = priority; dbTorrent.Priority = priority;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
} }
public async Task UpdateRetry(Guid torrentId, DateTimeOffset? dateTime, Int32 retryCount) public async Task UpdateRetry(Guid torrentId, DateTimeOffset? dateTime, Int32 retryCount)
@ -270,6 +278,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.Retry = dateTime; dbTorrent.Retry = dateTime;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
} }
public async Task UpdateError(Guid torrentId, String error) public async Task UpdateError(Guid torrentId, String error)
@ -284,27 +294,37 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.Error = error; dbTorrent.Error = error;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
} }
public async Task Delete(Guid torrentId) public async Task Delete(Guid torrentId)
{ {
await using var transaction = await dataContext.Database.BeginTransactionAsync(); var dbTorrent = await dataContext.Torrents.FirstOrDefaultAsync(m => m.TorrentId == torrentId);
await dataContext.Downloads if (dbTorrent == null)
.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; return;
} }
dataContext.Torrents.Remove(dbTorrent);
await dataContext.SaveChangesAsync();
await VoidCache();
}
public static async Task VoidCache()
{
await TorrentCacheLock.WaitAsync();
try
{
_torrentCache = null;
}
finally
{
TorrentCacheLock.Release();
}
} }
} }

View file

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

View file

@ -14,5 +14,5 @@ public enum DownloadClient
Symlink, Symlink,
[Description("Synology DownloadStation")] [Description("Synology DownloadStation")]
DownloadStation DownloadStation,
} }

View file

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

View file

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

View file

@ -2,7 +2,6 @@
public enum TorrentStatus public enum TorrentStatus
{ {
// Queued by RDTClient, not the provider.
Queued = 0, Queued = 0,
Processing = 1, Processing = 1,

View file

@ -5,4 +5,4 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Code style", Scope = "NamespaceAndDescendants", Target = "N:RdtClient.Data")] [assembly: SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Code style", Scope = "NamespaceAndDescendants", Target = "N:RdtClient.Web")]

View file

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

View file

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

View file

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

View file

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

View file

@ -44,19 +44,17 @@ public class Download
} }
/// <summary> /// <summary>
/// Used to create <see cref="Download" />s /// Used to create <see cref="Download"/>s
/// </summary> /// </summary>
public class DownloadInfo public class DownloadInfo
{ {
/// <summary> /// <summary>
/// The name of the file. Should not include directory. /// 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. /// If the filename is not known, set tn null and `GetFileName` will be called with the unrestricted link.
/// </summary> /// </summary>
public required String? FileName; public required String? FileName;
/// <summary> /// <summary>
/// The restricted link to download this download. If the debrid serice in question does not have restricted links, use /// 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
/// either a fake or the unrestricted link
/// </summary> /// </summary>
public required String RestrictedLink; public required String RestrictedLink;
} }

View file

@ -2,16 +2,12 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json; using System.Text.Json;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.TorrentClient;
namespace RdtClient.Data.Models.Data; namespace RdtClient.Data.Models.Data;
public class Torrent public class Torrent
{ {
private String? _rdFiles;
private IList<DebridClientFile> _filesCache = [];
private Boolean _filesCacheInitialized;
[Key] [Key]
public Guid TorrentId { get; set; } public Guid TorrentId { get; set; }
@ -21,7 +17,7 @@ public class Torrent
public TorrentDownloadAction DownloadAction { get; set; } public TorrentDownloadAction DownloadAction { get; set; }
public TorrentFinishedAction FinishedAction { get; set; } public TorrentFinishedAction FinishedAction { get; set; }
public Int32 FinishedActionDelay { get; set; } public Int32 FinishedActionDelay { get; set; }
public TorrentHostDownloadAction HostDownloadAction { get; set; } public TorrentHostDownloadAction HostDownloadAction { get; set; }
public Int32 DownloadMinSize { get; set; } public Int32 DownloadMinSize { get; set; }
public String? IncludeRegex { get; set; } public String? IncludeRegex { get; set; }
@ -34,7 +30,6 @@ public class Torrent
public DateTimeOffset? Completed { get; set; } public DateTimeOffset? Completed { get; set; }
public DateTimeOffset? Retry { get; set; } public DateTimeOffset? Retry { get; set; }
public DownloadType Type { get; set; }
public String? FileOrMagnet { get; set; } public String? FileOrMagnet { get; set; }
public Boolean IsFile { get; set; } public Boolean IsFile { get; set; }
@ -63,52 +58,26 @@ public class Torrent
public DateTimeOffset? RdEnded { get; set; } public DateTimeOffset? RdEnded { get; set; }
public Int64? RdSpeed { get; set; } public Int64? RdSpeed { get; set; }
public Int64? RdSeeders { get; set; } public Int64? RdSeeders { get; set; }
public String? RdFiles { get; set; }
public String? RdFiles
{
get => _rdFiles;
set
{
if (_rdFiles == value)
{
return;
}
_rdFiles = value;
_filesCache = [];
_filesCacheInitialized = false;
}
}
[NotMapped] [NotMapped]
public IList<DebridClientFile> Files public IList<TorrentClientFile> Files
{ {
get get
{ {
if (_filesCacheInitialized)
{
return _filesCache;
}
_filesCacheInitialized = true;
if (String.IsNullOrWhiteSpace(RdFiles)) if (String.IsNullOrWhiteSpace(RdFiles))
{ {
_filesCache = []; return [];
return _filesCache;
} }
try try
{ {
_filesCache = JsonSerializer.Deserialize<List<DebridClientFile>>(RdFiles) ?? []; return JsonSerializer.Deserialize<List<TorrentClientFile>>(RdFiles) ?? [];
} }
catch catch
{ {
_filesCache = []; return [];
} }
return _filesCache;
} }
} }

View file

@ -1,6 +1,5 @@
using System.ComponentModel; using RdtClient.Data.Enums;
using System.ComponentModel.DataAnnotations; using System.ComponentModel;
using RdtClient.Data.Enums;
// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global
@ -156,7 +155,6 @@ http://127.0.0.1:6800/jsonrpc.")]
[DisplayName("Synology DownloadStation Username")] [DisplayName("Synology DownloadStation Username")]
[Description("The username to use when connecting to the Synology DownloadStation.")] [Description("The username to use when connecting to the Synology DownloadStation.")]
public String? DownloadStationUsername { get; set; } = null; public String? DownloadStationUsername { get; set; } = null;
[DisplayName("Synology DownloadStation Password")] [DisplayName("Synology DownloadStation Password")]
[Description("The password to use when connecting to the Synology DownloadStation.")] [Description("The password to use when connecting to the Synology DownloadStation.")]
public String? DownloadStationPassword { get; set; } = null; public String? DownloadStationPassword { get; set; } = null;
@ -165,14 +163,6 @@ 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.")] [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; 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")] [DisplayName("Log level")]
[Description("Only set when trying to debug a download client, can generate a lot of logs.")] [Description("Only set when trying to debug a download client, can generate a lot of logs.")]
public DownloadClientLogLevel LogLevel { get; set; } = DownloadClientLogLevel.None; public DownloadClientLogLevel LogLevel { get; set; } = DownloadClientLogLevel.None;
@ -204,7 +194,7 @@ or
public String ApiKey { get; set; } = ""; public String ApiKey { get; set; } = "";
/// <summary> /// <summary>
/// API hostname to use <b>for Real Debrid only</b> /// API hostname to use <b>for Real Debrid only</b>
/// </summary> /// </summary>
[DisplayName("API Hostname (RD only)")] [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.")] [Description("Use this instead of the normal hostname for Real Debrid API requests. Only used by Real Debrid. Leave blank to use default.")]
@ -264,7 +254,6 @@ public class DbSettingsWatch
[DisplayName("Check Interval")] [DisplayName("Check Interval")]
[Description("Time in seconds to check the folder for new files.")] [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; public Int32 Interval { get; set; } = 60;
[DisplayName("Import Defaults")] [DisplayName("Import Defaults")]

View file

@ -1,9 +0,0 @@
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; }
}

View file

@ -1,6 +0,0 @@
namespace RdtClient.Data.Models.Internal;
public class RateLimitException(String message, TimeSpan retryAfter) : Exception(message)
{
public TimeSpan RetryAfter { get; } = retryAfter;
}

View file

@ -1,7 +0,0 @@
namespace RdtClient.Data.Models.Internal;
public class RateLimitStatus
{
public DateTimeOffset? NextDequeueTime { get; set; }
public Double SecondsRemaining { get; set; }
}

View file

@ -1,71 +0,0 @@
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; }
}

View file

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

View file

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

View file

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

View file

@ -1,24 +0,0 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.Sabnzbd;
public class SabnzbdCategory
{
[JsonPropertyName("name")]
public String Name { get; set; } = "default";
[JsonPropertyName("order")]
public Int32 Order { get; set; } = 0;
[JsonPropertyName("dir")]
public String Dir { get; set; } = "";
[JsonPropertyName("newzbin")]
public String Newzbin { get; set; } = "";
[JsonPropertyName("priority")]
public Int32 Priority { get; set; } = -100;
[JsonPropertyName("script")]
public String Script { get; set; } = "None";
}

View file

@ -1,15 +0,0 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.Sabnzbd;
public class SabnzbdConfig
{
[JsonPropertyName("misc")]
public SabnzbdMisc Misc { get; set; } = new();
[JsonPropertyName("categories")]
public List<SabnzbdCategory> Categories { get; set; } = new();
[JsonPropertyName("servers")]
public List<Object> Servers { get; set; } = new();
}

View file

@ -1,15 +0,0 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.Sabnzbd;
public class SabnzbdHistory
{
[JsonPropertyName("total_slots")]
public Int32 TotalSlots { get; set; }
[JsonPropertyName("noofslots")]
public Int32 NoOfSlots { get; set; }
[JsonPropertyName("slots")]
public List<SabnzbdHistorySlot> Slots { get; set; } = new();
}

View file

@ -1,24 +0,0 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.Sabnzbd;
public class SabnzbdHistorySlot
{
[JsonPropertyName("nzo_id")]
public String NzoId { get; set; } = "";
[JsonPropertyName("name")]
public String Name { get; set; } = "";
[JsonPropertyName("size")]
public String Size { get; set; } = "0 B";
[JsonPropertyName("status")]
public String Status { get; set; } = "Completed";
[JsonPropertyName("category")]
public String Category { get; set; } = "Default";
[JsonPropertyName("storage")]
public String Path { get; set; } = "";
}

View file

@ -1,18 +0,0 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.Sabnzbd;
public class SabnzbdMisc
{
[JsonPropertyName("complete_dir")]
public String CompleteDir { get; set; } = "";
[JsonPropertyName("download_dir")]
public String DownloadDir { get; set; } = "";
[JsonPropertyName("port")]
public String Port { get; set; } = "6500";
[JsonPropertyName("version")]
public String Version { get; set; } = "4.4.0";
}

View file

@ -1,27 +0,0 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.Sabnzbd;
public class SabnzbdQueue
{
[JsonPropertyName("version")]
public String Version { get; set; } = "4.4.0";
[JsonPropertyName("status")]
public String Status { get; set; } = "Idle";
[JsonPropertyName("speed")]
public String Speed { get; set; } = "0 ";
[JsonPropertyName("size")]
public String Size { get; set; } = "0 B";
[JsonPropertyName("sizeleft")]
public String SizeLeft { get; set; } = "0 B";
[JsonPropertyName("noofslots")]
public Int32 NoOfSlots { get; set; }
[JsonPropertyName("slots")]
public List<SabnzbdQueueSlot> Slots { get; set; } = new();
}

View file

@ -1,36 +0,0 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.Sabnzbd;
public class SabnzbdQueueSlot
{
[JsonPropertyName("index")]
public Int32 Index { get; set; }
[JsonPropertyName("nzo_id")]
public String NzoId { get; set; } = "";
[JsonPropertyName("filename")]
public String Filename { get; set; } = "";
[JsonPropertyName("size")]
public String Size { get; set; } = "0 B";
[JsonPropertyName("sizeleft")]
public String SizeLeft { get; set; } = "0 B";
[JsonPropertyName("percentage")]
public String Percentage { get; set; } = "0";
[JsonPropertyName("status")]
public String Status { get; set; } = "Downloading";
[JsonPropertyName("cat")]
public String Category { get; set; } = "Default";
[JsonPropertyName("timeleft")]
public String TimeLeft { get; set; } = "0:00:00";
[JsonPropertyName("priority")]
public String Priority { get; set; } = "Normal";
}

View file

@ -1,30 +0,0 @@
using System.Text.Json.Serialization;
namespace RdtClient.Data.Models.Sabnzbd;
public class SabnzbdResponse
{
[JsonPropertyName("queue")]
public SabnzbdQueue? Queue { get; set; }
[JsonPropertyName("history")]
public SabnzbdHistory? History { get; set; }
[JsonPropertyName("version")]
public String? Version { get; set; }
[JsonPropertyName("status")]
public Boolean? Status { get; set; }
[JsonPropertyName("error")]
public String? Error { get; set; }
[JsonPropertyName("nzo_ids")]
public List<String>? NzoIds { get; set; }
[JsonPropertyName("config")]
public SabnzbdConfig? Config { get; set; }
[JsonPropertyName("categories")]
public List<String>? Categories { get; set; }
}

View file

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

View file

@ -1,6 +1,6 @@
namespace RdtClient.Data.Models.DebridClient; namespace RdtClient.Data.Models.TorrentClient;
public class DebridClientFile public class TorrentClientFile
{ {
public Int64 Id { get; set; } public Int64 Id { get; set; }
public String Path { get; set; } = default!; public String Path { get; set; } = default!;

View file

@ -1,8 +1,6 @@
using RdtClient.Data.Enums; namespace RdtClient.Data.Models.TorrentClient;
namespace RdtClient.Data.Models.DebridClient; public class TorrentClientTorrent
public class DebridClientTorrent
{ {
public String Id { get; set; } = default!; public String Id { get; set; } = default!;
public String Filename { get; set; } = default!; public String Filename { get; set; } = default!;
@ -17,9 +15,7 @@ public class DebridClientTorrent
public String? Message { get; set; } public String? Message { get; set; }
public Int64 StatusCode { get; set; } public Int64 StatusCode { get; set; }
public DateTimeOffset? Added { 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 List<String>? Links { get; set; }
public DateTimeOffset? Ended { get; set; } public DateTimeOffset? Ended { get; set; }
public Int64? Speed { get; set; } public Int64? Speed { get; set; }

View file

@ -1,6 +1,6 @@
namespace RdtClient.Data.Models.DebridClient; namespace RdtClient.Data.Models.TorrentClient;
public class DebridClientUser public class TorrentClientUser
{ {
public String? Username { get; set; } public String? Username { get; set; }
public DateTimeOffset? Expiration { get; set; } public DateTimeOffset? Expiration { get; set; }

View file

@ -1,22 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,237 +0,0 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Models.Data;
using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Services;
namespace RdtClient.Service.Test.BackgroundServices;
public class WatchFolderCheckerTests : IDisposable
{
private readonly Mock<ILogger<WatchFolderChecker>> _loggerMock;
private readonly Mock<IServiceProvider> _scopeServiceProviderMock;
private readonly Mock<IServiceProvider> _serviceProviderMock;
private readonly Mock<IServiceScope> _serviceScopeMock;
private readonly String _testPath;
private readonly TestSettings _settings;
private readonly Mock<Torrents> _torrentsServiceMock;
public WatchFolderCheckerTests()
{
_loggerMock = new();
_serviceProviderMock = new();
_serviceScopeMock = new();
_scopeServiceProviderMock = new();
_settings = new(new()
{
Watch = new()
{
Interval = 0,
Default = new()
},
DownloadClient = new()
});
_torrentsServiceMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_serviceProviderMock
.Setup(x => x.GetService(typeof(IServiceScopeFactory)))
.Returns(new MockScopeFactory(_serviceScopeMock.Object));
_serviceScopeMock
.Setup(x => x.ServiceProvider)
.Returns(_scopeServiceProviderMock.Object);
_scopeServiceProviderMock
.Setup(x => x.GetService(typeof(Torrents)))
.Returns(_torrentsServiceMock.Object);
_testPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(_testPath);
SetStartupReady(true);
_settings.Current.Watch.Path = _testPath;
}
public void Dispose()
{
if (Directory.Exists(_testPath))
{
Directory.Delete(_testPath, true);
}
}
private static void SetStartupReady(Boolean ready)
{
var property = typeof(Startup).GetProperty("Ready", BindingFlags.Public | BindingFlags.Static);
property?.SetValue(null, ready);
}
private static void ResetPrevCheck(WatchFolderChecker checker)
{
var field = typeof(WatchFolderChecker).GetField("_prevCheck", BindingFlags.NonPublic | BindingFlags.Instance);
field?.SetValue(checker, DateTime.MinValue);
}
[Fact]
public async Task ExecuteAsync_ShouldProcessTorrentFile()
{
// Arrange
var filePath = Path.Combine(_testPath, "test.torrent");
var content = "torrent content"u8.ToArray();
await File.WriteAllBytesAsync(filePath, content);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object, _settings);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
_torrentsServiceMock
.Setup(x => x.AddFileToDebridQueue(It.IsAny<Byte[]>(), It.IsAny<Torrent>()))
.ReturnsAsync(new Torrent());
// Act
var task = checker.StartAsync(cts.Token);
await Task.Delay(500); // Give it some time to process
await cts.CancelAsync();
try
{
await task;
}
catch (OperationCanceledException)
{
}
// Assert
_torrentsServiceMock.Verify(x => x.AddFileToDebridQueue(It.Is<Byte[]>(b => b.AsEnumerable().SequenceEqual(content)),
It.IsAny<Torrent>()),
Times.AtLeastOnce);
Assert.False(File.Exists(filePath));
Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.torrent")));
}
[Fact]
public async Task ExecuteAsync_ShouldProcessMagnetFile()
{
// Arrange
var filePath = Path.Combine(_testPath, "test.magnet");
var content = "magnet:?xt=urn:btih:123";
await File.WriteAllTextAsync(filePath, content);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object, _settings);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
_torrentsServiceMock
.Setup(x => x.AddMagnetToDebridQueue(It.IsAny<String>(), It.IsAny<Torrent>()))
.ReturnsAsync(new Torrent());
// Act
var task = checker.StartAsync(cts.Token);
await Task.Delay(500);
await cts.CancelAsync();
try
{
await task;
}
catch (OperationCanceledException)
{
}
// Assert
_torrentsServiceMock.Verify(x => x.AddMagnetToDebridQueue(content,
It.IsAny<Torrent>()),
Times.AtLeastOnce);
Assert.False(File.Exists(filePath));
Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.magnet")));
}
[Fact]
public async Task ExecuteAsync_ShouldProcessNzbFile()
{
// Arrange
var filePath = Path.Combine(_testPath, "test.nzb");
var content = "nzb content"u8.ToArray();
await File.WriteAllBytesAsync(filePath, content);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object, _settings);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
_torrentsServiceMock
.Setup(x => x.AddNzbFileToDebridQueue(It.IsAny<Byte[]>(), It.IsAny<String>(), It.IsAny<Torrent>()))
.ReturnsAsync(new Torrent());
// Act
var task = checker.StartAsync(cts.Token);
await Task.Delay(500);
await cts.CancelAsync();
try
{
await task;
}
catch (OperationCanceledException)
{
}
// Assert
_torrentsServiceMock.Verify(x => x.AddNzbFileToDebridQueue(It.Is<Byte[]>(b => b.AsEnumerable().SequenceEqual(content)),
"test.nzb",
It.IsAny<Torrent>()),
Times.AtLeastOnce);
Assert.False(File.Exists(filePath));
Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.nzb")));
}
[Fact]
public async Task ExecuteAsync_ShouldIgnoreOtherFiles()
{
// Arrange
var filePath = Path.Combine(_testPath, "test.txt");
await File.WriteAllTextAsync(filePath, "ignore me");
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object, _settings);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
// Act
var task = checker.StartAsync(cts.Token);
await Task.Delay(500);
await cts.CancelAsync();
try
{
await task;
}
catch (OperationCanceledException)
{
}
// Assert
_torrentsServiceMock.Verify(x => x.AddFileToDebridQueue(It.IsAny<Byte[]>(), It.IsAny<Torrent>()), Times.Never);
_torrentsServiceMock.Verify(x => x.AddMagnetToDebridQueue(It.IsAny<String>(), It.IsAny<Torrent>()), Times.Never);
_torrentsServiceMock.Verify(x => x.AddNzbFileToDebridQueue(It.IsAny<Byte[]>(), It.IsAny<String>(), It.IsAny<Torrent>()), Times.Never);
Assert.True(File.Exists(filePath));
}
private class MockScopeFactory(IServiceScope scope) : IServiceScopeFactory
{
public IServiceScope CreateScope()
{
return scope;
}
}
}

View file

@ -1,14 +0,0 @@
using System.Diagnostics.CodeAnalysis;
[assembly:
SuppressMessage("Usage",
"xUnit1045:Avoid using TheoryData type arguments that might not be serializable",
Justification = "It is serializable.",
Scope = "NamespaceAndDescendants",
Target = "N:RdtClient.Service.Test")]
[assembly:
SuppressMessage("Performance",
"SYSLIB1045:Convert to 'GeneratedRegexAttribute'.",
Justification = "We don't care for unit tests.",
Scope = "NamespaceAndDescendants",
Target = "N:RdtClient.Service.Test")]

View file

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

View file

@ -1,7 +1,7 @@
using System.IO.Abstractions.TestingHelpers; using System.IO.Abstractions.TestingHelpers;
using System.Text.Json; using System.Text.Json;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.TorrentClient;
using RdtClient.Service.Helpers; using RdtClient.Service.Helpers;
namespace RdtClient.Service.Test.Helpers; namespace RdtClient.Service.Test.Helpers;
@ -181,9 +181,9 @@ public class DownloadHelperTest
FileName = "file.txt" FileName = "file.txt"
}; };
var fileRelativePath = Path.Combine("inside", "lots", "of", "subdirectories", "file.txt"); var fileRelativePath = "inside/lots/of/subdirectories/file.txt";
IList<DebridClientFile> files = IList<TorrentClientFile> files =
[ [
new() new()
{ {
@ -203,7 +203,7 @@ public class DownloadHelperTest
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem); var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem);
// Assert // Assert
var expectedPath = Path.Combine("/data/downloads", torrent.RdName, "inside", "lots", "of", "subdirectories", "file.txt"); var expectedPath = Path.Combine("/data/downloads", torrent.RdName, fileRelativePath);
Assert.Equal(expectedPath, path); Assert.Equal(expectedPath, path);
} }
@ -217,9 +217,9 @@ public class DownloadHelperTest
FileName = "file.txt" FileName = "file.txt"
}; };
var fileRelativePath = Path.Combine("inside", "lots", "of", "subdirectories", "file.txt"); var fileRelativePath = "inside/lots/of/subdirectories/file.txt";
IList<DebridClientFile> files = IList<TorrentClientFile> files =
[ [
new() new()
{ {
@ -237,78 +237,7 @@ public class DownloadHelperTest
var path = DownloadHelper.GetDownloadPath(torrent, download); var path = DownloadHelper.GetDownloadPath(torrent, download);
// Assert // Assert
var expectedPath = Path.Combine(torrent.RdName, "inside", "lots", "of", "subdirectories", "file.txt"); var expectedPath = Path.Combine(torrent.RdName, fileRelativePath);
Assert.Equal(expectedPath, path);
}
[Fact]
public void GetDownloadPath_WithPath_WhenFilePathStartsWithTorrentName_StripsPrefix()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var fileRelativePath = Path.Combine("Torrent Name", "Saison 1", "file.txt");
IList<DebridClientFile> files =
[
new()
{
Path = fileRelativePath
}
];
var torrent = new Torrent
{
RdName = "Torrent Name",
RdFiles = JsonSerializer.Serialize(files)
};
var fileSystem = new MockFileSystem();
// Act
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem);
// Assert
// The torrent name prefix in the file path should not duplicate the torrent name in the base dir
var expectedPath = Path.Combine("/data/downloads", "Torrent Name", "Saison 1", "file.txt");
Assert.Equal(expectedPath, path);
}
[Fact]
public void GetDownloadPath_WithoutPath_WhenFilePathStartsWithTorrentName_StripsPrefix()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var fileRelativePath = Path.Combine("Torrent Name", "Saison 1", "file.txt");
IList<DebridClientFile> files =
[
new()
{
Path = fileRelativePath
}
];
var torrent = new Torrent
{
RdName = "Torrent Name",
RdFiles = JsonSerializer.Serialize(files)
};
// Act
var path = DownloadHelper.GetDownloadPath(torrent, download);
// Assert
var expectedPath = Path.Combine("Torrent Name", "Saison 1", "file.txt");
Assert.Equal(expectedPath, path); Assert.Equal(expectedPath, path);
} }

View file

@ -1,101 +0,0 @@
using RdtClient.Service.Helpers;
namespace RdtClient.Service.Test.Helpers;
public class FileHelperTest
{
[Fact]
public void RemoveInvalidFileNameChars_RemovesInvalidChars()
{
// Arrange
var invalidChars = Path.GetInvalidFileNameChars();
var input = "test" + new String(invalidChars) + "file.txt";
// Act
var result = FileHelper.RemoveInvalidFileNameChars(input);
// Assert
Assert.Equal("testfile.txt", result);
}
[Fact]
public void RemoveInvalidFileNameChars_RemovesDirectorySeparators()
{
// Arrange
var input = "folder/subfolder\\file.txt";
// Act
var result = FileHelper.RemoveInvalidFileNameChars(input);
// Assert
Assert.Equal("foldersubfolderfile.txt", result);
}
[Fact]
public void RemoveInvalidFileNameChars_RemovesDoubleDots()
{
// Arrange
var input = "test..file.txt";
// Act
var result = FileHelper.RemoveInvalidFileNameChars(input);
// Assert
Assert.Equal("testfile.txt", result);
}
[Fact]
public void RemoveInvalidFileNameChars_TrimsLeadingSeparators()
{
// Arrange
var input = "/test/file.txt";
// Act
var result = FileHelper.RemoveInvalidFileNameChars(input);
// Assert
// Note: The method first splits by invalid chars (including /),
// so "/test/file.txt" becomes "testfile.txt" through String.Concat(Split).
// Then it trims leading separators.
Assert.Equal("testfile.txt", result);
}
[Fact]
public void RemoveInvalidFileNameChars_HandlesValidFileName()
{
// Arrange
var input = "valid_file-123.txt";
// Act
var result = FileHelper.RemoveInvalidFileNameChars(input);
// Assert
Assert.Equal("valid_file-123.txt", result);
}
[Fact]
public void RemoveInvalidFileNameChars_HandlesEmptyString()
{
// Arrange
var input = "";
// Act
var result = FileHelper.RemoveInvalidFileNameChars(input);
// Assert
Assert.Equal("", result);
}
[Theory]
[InlineData("test/../file.txt", "testfile.txt")]
[InlineData(".../test.txt", ".test.txt")]
[InlineData("test....txt", "testtxt")]
public void RemoveInvalidFileNameChars_ComplexCases(String input, String expected)
{
// Act
var result = FileHelper.RemoveInvalidFileNameChars(input);
// Assert
Assert.Equal(expected, result);
}
}

View file

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

View file

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

View file

@ -1,28 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<AssemblyName>RdtClient.Service.Test</AssemblyName> <AssemblyName>RdtClient.Service.Test</AssemblyName>
<RootNamespace>RdtClient.Service.Test</RootNamespace> <RootNamespace>RdtClient.Service.Test</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AllDebrid.NET" Version="1.0.18" /> <PackageReference Include="AllDebrid.NET" Version="1.0.18" />
<PackageReference Include="coverlet.collector" Version="10.0.1"> <PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="SharpCompress" Version="[0.42.1]" /> <PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.0.16" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.1" /> <PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.0.16" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.1" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.16" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.1" />
<PackageReference Include="TorBox.NET" Version="2.1.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View file

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

View file

@ -28,11 +28,10 @@ public class DownloadableFileFilterTest
} }
[Theory] [Theory]
// downloadMinSize is in MB, fileSize is in B // downloadMinSize is in MB, fileSize is in B
[InlineData(100, 20 * 1024 * 1024)] [InlineData(100, 20 * 1024 * 1024)]
[InlineData(2, 2 * 1024 * 1024)] [InlineData(2, 2 * 1024 * 1024)]
[InlineData(2, 2 * ((1000 * 1000) + 1))] // mostly to show we use 1024 not 1000 for conversion [InlineData(2, 2 * (1000 * 1000 + 1))] // mostly to show we use 1024 not 1000 for conversion
public void IsDownloadable_WhenDownloadMinSizeSpecified_AndDownloadBelowSize_ReturnsFalse(Int32 downloadMinSize, Int64 fileSize) public void IsDownloadable_WhenDownloadMinSizeSpecified_AndDownloadBelowSize_ReturnsFalse(Int32 downloadMinSize, Int64 fileSize)
{ {
// Arrange // Arrange
@ -55,7 +54,7 @@ public class DownloadableFileFilterTest
[Theory] [Theory]
[InlineData(100, 110 * 1024 * 1024)] [InlineData(100, 110 * 1024 * 1024)]
[InlineData(2, (2 * 1024 * 1024) + 1)] [InlineData(2, 2 * 1024 * 1024 + 1)]
public void IsDownloadable_WhenDownloadMinSizeSpecified_AndDownloadAboveSize_ReturnsTrue(Int32 downloadMinSize, Int64 fileSize) public void IsDownloadable_WhenDownloadMinSizeSpecified_AndDownloadAboveSize_ReturnsTrue(Int32 downloadMinSize, Int64 fileSize)
{ {
// Arrange // Arrange
@ -203,8 +202,9 @@ public class DownloadableFileFilterTest
} }
[Theory] [Theory]
[InlineData(10, "file", (10 * 1024 * 1024) + 1, "no-match.txt")] [InlineData(10, "file", 10 * 1024 * 1024 + 1, "no-match.txt")]
public void IsDownloadable_WhenBothDownloadMinSizeAndIncludeRegexSpecified_AndDownloadAboveSizeAndDoesNotMatchRegex_ReturnsFalse(Int32 minSize, public void IsDownloadable_WhenBothDownloadMinSizeAndIncludeRegexSpecified_AndDownloadAboveSizeAndDoesNotMatchRegex_ReturnsFalse(
Int32 minSize,
String includeRegex, String includeRegex,
Int64 fileSize, Int64 fileSize,
String filePath) String filePath)
@ -229,8 +229,9 @@ public class DownloadableFileFilterTest
} }
[Theory] [Theory]
[InlineData(10, "file", (10 * 1024 * 1024) - 1, "file.txt")] [InlineData(10, "file", 10 * 1024 * 1024 - 1, "file.txt")]
public void IsDownloadable_WhenBothDownloadMinSizeAndIncludeRegexSpecified_AndDownloadBelowSizeAndMatchesRegex_ReturnsFalse(Int32 minSize, public void IsDownloadable_WhenBothDownloadMinSizeAndIncludeRegexSpecified_AndDownloadBelowSizeAndMatchesRegex_ReturnsFalse(
Int32 minSize,
String includeRegex, String includeRegex,
Int64 fileSize, Int64 fileSize,
String filePath) String filePath)
@ -253,7 +254,6 @@ public class DownloadableFileFilterTest
// Assert // Assert
Assert.False(result); Assert.False(result);
} }
private class Mocks private class Mocks
{ {
public readonly Mock<ILogger<DownloadableFileFilter>> LoggerMock = new(); public readonly Mock<ILogger<DownloadableFileFilter>> LoggerMock = new();

View file

@ -7,7 +7,7 @@ using Synology.Api.Client.Apis.DownloadStation.Task.Models;
namespace RdtClient.Service.Test.Services.Downloaders; namespace RdtClient.Service.Test.Services.Downloaders;
internal class Mocks class Mocks
{ {
public readonly String Gid; public readonly String Gid;
public readonly Mock<ISynologyClient> SynologyClientMock = new(); public readonly Mock<ISynologyClient> SynologyClientMock = new();
@ -22,7 +22,7 @@ internal class Mocks
} }
} }
internal class FakeDelayProvider : IDelayProvider class FakeDelayProvider : IDelayProvider
{ {
public Task Delay(Int32 milliseconds) public Task Delay(Int32 milliseconds)
{ {

View file

@ -1,18 +1,14 @@
using System.Web;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MonoTorrent.BEncoding;
using Moq; using Moq;
using RdtClient.Service.Services; using RdtClient.Service.Services;
using MonoTorrent.BEncoding;
namespace RdtClient.Service.Test.Services; namespace RdtClient.Service.Test.Services;
public class EnricherTest : IDisposable public class EnricherTest : IDisposable
{ {
private const String TestMagnetLink =
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=TestFile&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce";
private readonly Mock<ILogger<Enricher>> _loggerMock;
private readonly MockRepository _mockRepository; private readonly MockRepository _mockRepository;
private readonly Mock<ILogger<Enricher>> _loggerMock;
private readonly Mock<ITrackerListGrabber> _trackerListGrabberMock; private readonly Mock<ITrackerListGrabber> _trackerListGrabberMock;
public EnricherTest() public EnricherTest()
@ -27,6 +23,9 @@ public class EnricherTest : IDisposable
_mockRepository.VerifyAll(); _mockRepository.VerifyAll();
} }
private const String TestMagnetLink =
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=TestFile&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce";
// Helper methods for creating BEncodedDictionary objects for torrents // Helper methods for creating BEncodedDictionary objects for torrents
private static BEncodedDictionary CreateStandardTorrentDict(String announceUrl, List<String>? announceListTier = null) private static BEncodedDictionary CreateStandardTorrentDict(String announceUrl, List<String>? announceListTier = null)
{ {
@ -217,7 +216,7 @@ public class EnricherTest : IDisposable
var result = await enricher.EnrichMagnetLink(magnetLink); var result = await enricher.EnrichMagnetLink(magnetLink);
// Assert // Assert
var queryParams = HttpUtility.ParseQueryString(new Uri(result).Query); var queryParams = System.Web.HttpUtility.ParseQueryString(new Uri(result).Query);
Assert.Equal("urn:btih:HASH", queryParams["xt"]); Assert.Equal("urn:btih:HASH", queryParams["xt"]);
Assert.Equal("MyFile", queryParams["dn"]); Assert.Equal("MyFile", queryParams["dn"]);
} }
@ -240,7 +239,7 @@ public class EnricherTest : IDisposable
var result = await enricher.EnrichMagnetLink(magnetLink); var result = await enricher.EnrichMagnetLink(magnetLink);
// Assert // Assert
var queryParams = HttpUtility.ParseQueryString(new Uri(result).Query); var queryParams = System.Web.HttpUtility.ParseQueryString(new Uri(result).Query);
var trValues = queryParams.GetValues("tr")?.ToList() ?? new List<String>(); var trValues = queryParams.GetValues("tr")?.ToList() ?? new List<String>();
Assert.Contains("udp://existing", trValues); Assert.Contains("udp://existing", trValues);

View file

@ -1,149 +0,0 @@
using System.IO.Abstractions.TestingHelpers;
using System.Text;
using Moq;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using TorrentRunnerState = RdtClient.Service.Services.TorrentRunnerState;
using TorrentsService = RdtClient.Service.Services.Torrents;
namespace RdtClient.Service.Test.Services;
public class NzbTorrentsTest
{
private readonly MockFileSystem _fileSystem;
private readonly Mocks _mocks;
private readonly TorrentsService _torrents;
public NzbTorrentsTest()
{
_mocks = new();
_fileSystem = new();
_torrents = new(_mocks.TorrentsLoggerMock.Object,
_mocks.TorrentDataMock.Object,
_mocks.DownloadsMock.Object,
_mocks.ProcessFactoryMock.Object,
_fileSystem,
_mocks.EnricherMock.Object,
null!,
null!,
null!,
null!,
null!,
new TestSettings(),
new TorrentRunnerState());
}
[Fact]
public async Task AddNzbLinkToDebridQueue_ValidLink_AddsToQueue()
{
// Arrange
var nzbLink = "http://example.com/test.nzb";
var torrent = new Torrent
{
DownloadClient = DownloadClient.Bezzad
};
_mocks.TorrentDataMock.Setup(t => t.GetByHash(It.IsAny<String>())).ReturnsAsync((Torrent)null!);
_mocks.TorrentDataMock.Setup(t => t.Add(null,
It.IsAny<String>(),
nzbLink,
false,
DownloadType.Nzb,
torrent.DownloadClient,
It.IsAny<Torrent>()))
.ReturnsAsync(new Torrent
{
Hash = "mockHash",
RdName = "test.nzb"
});
// Act
var result = await _torrents.AddNzbLinkToDebridQueue(nzbLink, torrent);
// Assert
Assert.NotNull(result);
Assert.Equal("test.nzb", torrent.RdName);
Assert.Equal(TorrentStatus.Queued, torrent.RdStatus);
_mocks.TorrentDataMock.Verify(t => t.Add(null,
It.IsAny<String>(),
nzbLink,
false,
DownloadType.Nzb,
torrent.DownloadClient,
torrent),
Times.Once);
}
[Fact]
public async Task AddNzbLinkToDebridQueue_InvalidLink_ThrowsException()
{
// Arrange
var nzbLink = "invalid-link";
var torrent = new Torrent();
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _torrents.AddNzbLinkToDebridQueue(nzbLink, torrent));
}
[Fact]
public async Task AddNzbFileToDebridQueue_ValidFile_AddsToQueue()
{
// Arrange
var nzbContent =
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>\r\n<nzb xmlns=\"http://www.newzbin.com/DTD/2003/nzb\">\r\n <head>\r\n <meta type=\"title\">Test NZB Title</meta>\r\n </head>\r\n</nzb>";
var bytes = Encoding.UTF8.GetBytes(nzbContent);
var torrent = new Torrent
{
DownloadClient = DownloadClient.Bezzad
};
_mocks.TorrentDataMock.Setup(t => t.GetByHash(It.IsAny<String>())).ReturnsAsync((Torrent)null!);
_mocks.TorrentDataMock.Setup(t => t.Add(null,
It.IsAny<String>(),
It.IsAny<String>(),
true,
DownloadType.Nzb,
torrent.DownloadClient,
It.IsAny<Torrent>()))
.ReturnsAsync(new Torrent
{
Hash = "mockHash",
RdName = "Test NZB Title"
});
// Act
var result = await _torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent);
// Assert
Assert.NotNull(result);
Assert.Equal("Test NZB Title", torrent.RdName);
Assert.Equal(TorrentStatus.Queued, torrent.RdStatus);
_mocks.TorrentDataMock.Verify(t => t.Add(null,
It.IsAny<String>(),
Convert.ToBase64String(bytes),
true,
DownloadType.Nzb,
torrent.DownloadClient,
torrent),
Times.Once);
}
[Fact]
public async Task AddNzbFileToDebridQueue_InvalidXml_ThrowsException()
{
// Arrange
var bytes = Encoding.UTF8.GetBytes("not xml");
var torrent = new Torrent();
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent));
}
}

View file

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

View file

@ -1,475 +0,0 @@
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services;
namespace RdtClient.Service.Test.Services;
public class SabnzbdTest
{
private readonly AppSettings _appSettings = new()
{
Port = 6500
};
private readonly Mock<ILogger<Sabnzbd>> _loggerMock = new();
private readonly TestSettings _settings;
private readonly Mock<Torrents> _torrentsMock;
public SabnzbdTest()
{
_settings = new();
_torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, _settings, new TorrentRunnerState());
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(new List<Torrent>());
}
[Fact]
public async Task GetQueue_ShouldReturnCorrectStructure()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "Name 1",
RdProgress = 50,
Type = DownloadType.Nzb,
Downloads = new List<Download>
{
new()
{
BytesTotal = 1000,
BytesDone = 500
}
}
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
_torrentsMock.Setup(t => t.GetDownloadStats(It.IsAny<Guid>())).Returns((0, 1000, 500));
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
Assert.NotNull(result);
Assert.Single(result.Slots);
Assert.Equal("hash1", result.Slots[0].NzoId);
Assert.Equal("Name 1", result.Slots[0].Filename);
// (50% debrid + 50% download) / 2 = 50%
Assert.Equal("50", result.Slots[0].Percentage);
}
[Fact]
public async Task GetQueue_ShouldCalculatePercentageCorrectly_WhenDifferentProgress()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "Name 1",
RdProgress = 100,
Type = DownloadType.Nzb,
Downloads = new List<Download>
{
new()
{
BytesTotal = 1000,
BytesDone = 0
}
}
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
// (100% debrid + 0% download) / 2 = 50%
Assert.Equal("50", result.Slots[0].Percentage);
}
[Fact]
public async Task GetQueue_ShouldCalculateTimeLeftCorrectly()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var added = now.AddMinutes(-10);
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "Name 1",
Added = added,
RdProgress = 100, // 100% debrid
Type = DownloadType.Nzb,
Downloads = new List<Download>
{
new()
{
BytesTotal = 1000,
BytesDone = 0 // 0% download
}
}
}
};
// Overall progress = (1.0 + 0.0) / 2 = 0.5
// Elapsed = 10 minutes
// Total estimated = 10 / 0.5 = 20 minutes
// Time left = 20 - 10 = 10 minutes
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
// Allow for some small variation in time calculation during test execution
var timeLeftParts = result.Slots[0].TimeLeft.Split(':');
var hours = Int32.Parse(timeLeftParts[0]);
var minutes = Int32.Parse(timeLeftParts[1]);
Assert.Equal(0, hours);
Assert.InRange(minutes, 9, 11);
}
[Fact]
public async Task GetQueue_ShouldCalculateTimeLeftCorrectly_WithRetry()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var added = now.AddMinutes(-20);
var retry = now.AddMinutes(-10);
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "Name 1",
Added = added,
Retry = retry,
RdProgress = 100, // 100% debrid
Type = DownloadType.Nzb,
Downloads = new List<Download>
{
new()
{
BytesTotal = 1000,
BytesDone = 0 // 0% download
}
}
}
};
// Later of Added and Retry is Retry (-10 mins)
// Overall progress = (1.0 + 0.0) / 2 = 0.5
// Elapsed = 10 minutes
// Total estimated = 10 / 0.5 = 20 minutes
// Time left = 20 - 10 = 10 minutes
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
var timeLeftParts = result.Slots[0].TimeLeft.Split(':');
var hours = Int32.Parse(timeLeftParts[0]);
var minutes = Int32.Parse(timeLeftParts[1]);
Assert.Equal(0, hours);
Assert.InRange(minutes, 9, 11);
}
[Fact]
public async Task GetQueue_ShouldOnlyReturnNzbs()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Type = DownloadType.Nzb,
Downloads = new List<Download>()
},
new()
{
Hash = "hash2",
RdName = "Torrent 1",
Type = DownloadType.Torrent,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
Assert.Single(result.Slots);
Assert.Equal("hash1", result.Slots[0].NzoId);
}
[Fact]
public async Task GetHistory_ShouldOnlyReturnNzbs()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Type = DownloadType.Nzb,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>()
},
new()
{
Hash = "hash2",
RdName = "Torrent 1",
Type = DownloadType.Torrent,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetHistory();
// Assert
Assert.Single(result.Slots);
Assert.Equal("hash1", result.Slots[0].NzoId);
}
[Fact]
public async Task GetHistory_ShouldReturnFullPath()
{
// Arrange
var savePath = @"C:\Downloads";
_settings.Current.DownloadClient.MappedPath = savePath;
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Category = "radarr",
Type = DownloadType.Nzb,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetHistory();
// Assert
Assert.Single(result.Slots);
var expectedPath = Path.Combine(savePath, "radarr", "NZB 1");
Assert.Equal(expectedPath, result.Slots[0].Path);
}
[Fact]
public async Task GetHistory_ShouldReturnFullPath_NoCategory()
{
// Arrange
var savePath = @"C:\Downloads";
_settings.Current.DownloadClient.MappedPath = savePath;
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Category = null,
Type = DownloadType.Nzb,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetHistory();
// Assert
Assert.Single(result.Slots);
var expectedPath = Path.Combine(savePath, "NZB 1");
Assert.Equal(expectedPath, result.Slots[0].Path);
}
[Fact]
public async Task GetHistory_ShouldReturnFailedStatus_WhenTorrentHasError()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Type = DownloadType.Nzb,
Completed = DateTimeOffset.UtcNow,
Error = "Some error occurred",
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = await sabnzbd.GetHistory();
// Assert
Assert.Single(result.Slots);
Assert.Equal("Failed", result.Slots[0].Status);
}
[Theory]
[InlineData(TorrentFinishedAction.RemoveAllTorrents, true, true, true, true)]
[InlineData(TorrentFinishedAction.RemoveAllTorrents, false, true, true, false)]
[InlineData(TorrentFinishedAction.RemoveRealDebrid, true, false, true, true)]
[InlineData(TorrentFinishedAction.RemoveRealDebrid, false, false, true, false)]
[InlineData(TorrentFinishedAction.RemoveClient, true, true, false, true)]
[InlineData(TorrentFinishedAction.RemoveClient, false, true, false, false)]
public async Task Delete_ShouldRespectFinishedActionAndDeleteFiles(
TorrentFinishedAction finishedAction,
Boolean deleteFiles,
Boolean expectedDeleteData,
Boolean expectedDeleteRdTorrent,
Boolean expectedDeleteLocalFiles)
{
// Arrange
var torrentId = Guid.NewGuid();
_settings.Current.Integrations.Default.FinishedAction = finishedAction;
_torrentsMock.Setup(t => t.GetByHash("hash1"))
.ReturnsAsync(new Torrent
{
TorrentId = torrentId,
Hash = "hash1",
Type = DownloadType.Nzb
});
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
await sabnzbd.Delete("hash1", deleteFiles);
// Assert
_torrentsMock.Verify(t => t.Delete(torrentId, expectedDeleteData, expectedDeleteRdTorrent, expectedDeleteLocalFiles), Times.Once);
}
[Fact]
public async Task Delete_ShouldNotDelete_WhenFinishedActionIsNone()
{
// Arrange
_settings.Current.Integrations.Default.FinishedAction = TorrentFinishedAction.None;
_torrentsMock.Setup(t => t.GetByHash("hash1"))
.ReturnsAsync(new Torrent
{
TorrentId = Guid.NewGuid(),
Hash = "hash1",
Type = DownloadType.Nzb
});
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
await sabnzbd.Delete("hash1", true);
// Assert
_torrentsMock.Verify(t => t.Delete(It.IsAny<Guid>(), It.IsAny<Boolean>(), It.IsAny<Boolean>(), It.IsAny<Boolean>()), Times.Never);
}
[Fact]
public void GetConfig_ShouldReturnCorrectConfig()
{
// Arrange
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = sabnzbd.GetConfig();
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Misc);
Assert.Equal("6500", result.Misc.Port);
Assert.NotEmpty(result.Categories);
Assert.Contains(result.Categories, c => c.Name == "*");
}
[Fact]
public void GetCategories_ShouldOnlyReturnSettingsCategories()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
Category = "Movie",
Type = DownloadType.Nzb,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
_settings.Current.General.Categories = "TV, Music, *";
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings, _settings);
// Act
var result = sabnzbd.GetCategories();
// Assert
Assert.Equal("*", result[0]);
Assert.Contains("TV", result);
Assert.Contains("Music", result);
Assert.DoesNotContain("Movie", result);
Assert.Equal(3, result.Count);
}
}

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