Compare commits

..

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

239 changed files with 30601 additions and 26715 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

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

View file

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

View file

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

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

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

View file

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

1
.gitignore vendored
View file

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

View file

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

View file

@ -1,5 +1,5 @@
# Stage 1 - Build the frontend # Stage 1 - Build the frontend
FROM node:lts-alpine AS node-build-env FROM node:18-alpine3.18 AS node-build-env
ARG TARGETPLATFORM ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG BUILDPLATFORM ARG BUILDPLATFORM
@ -10,18 +10,18 @@ WORKDIR /appclient
RUN apk add --no-cache git python3 py3-pip make g++ RUN apk add --no-cache git python3 py3-pip make g++
COPY client ./client
COPY root ./root
RUN \ RUN \
echo "**** Cloning Source Code ****" && \
git clone https://github.com/rogerfar/rdt-client.git . && \
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-bookworm-slim-amd64 AS dotnet-build-env
ARG TARGETPLATFORM ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG BUILDPLATFORM ARG BUILDPLATFORM
@ -30,13 +30,12 @@ ENV BUILDPLATFORM=${BUILDPLATFORM:-linux/amd64}
RUN mkdir /appserver RUN mkdir /appserver
WORKDIR /appserver WORKDIR /appserver
COPY server ./server
RUN \ RUN \
echo "**** Cloning Source Code ****" && \
git clone https://github.com/rogerfar/rdt-client.git . && \
echo "**** Building Source Code for $TARGETPLATFORM on $BUILDPLATFORM ****" && \ echo "**** Building Source Code for $TARGETPLATFORM on $BUILDPLATFORM ****" && \
cd server && \ cd server && \
dotnet restore --no-cache RdtClient.sln && \ dotnet restore --no-cache RdtClient.sln && dotnet publish --no-restore -c Release -o out ;
dotnet test && \
dotnet publish --no-restore -c Release -o out ;
# Stage 3 - Build runtime image # Stage 3 - Build runtime image
FROM ghcr.io/linuxserver/baseimage-alpine:3.20 FROM ghcr.io/linuxserver/baseimage-alpine:3.20
@ -67,14 +66,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 \
@ -90,7 +89,7 @@ ENV PATH "$PATH:/usr/share/dotnet"
# Copy files for app # Copy files for app
WORKDIR /app WORKDIR /app
COPY --from=dotnet-build-env /appserver/server/out . COPY --from=dotnet-build-env /appserver/server/out .
COPY --from=node-build-env /appclient/client/out/browser ./wwwroot COPY --from=node-build-env /appclient/client/out ./wwwroot
COPY --from=node-build-env /appclient/root/ / COPY --from=node-build-env /appclient/root/ /
# ports and volumes # ports and volumes

View file

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

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 or Premiumize. 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 or Premiumize 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 or Premiumize!**
[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)
@ -17,10 +16,6 @@ This is a web interface to manage your torrents on Real-Debrid, AllDebrid, Premi
[Click here to sign up for Premiumize.](https://www.premiumize.me/) [Click here to sign up for Premiumize.](https://www.premiumize.me/)
[Click here to sign up for TorBox.](https://torbox.app/subscription?referral=3d25018e-f30d-4c4b-a714-48c04bc76765)
[Click here to sign up for DebridLink.](https://debrid-link.fr/id/6duif)
<sub>(referal links so I can get a few free premium days)</sub> <sub>(referal links so I can get a few free premium days)</sub>
## Docker Setup ## Docker Setup
@ -35,7 +30,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 .NET 9 Runtime 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 +52,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>```
@ -112,6 +107,16 @@ If you use Proxmox for your homelab, you can run rdt-client in a linux container
Currently there 4 available download clients: Currently there 4 available download clients:
#### Internal Downloader
This experimental [downloader](https://github.com/rogerfar/Downloader.NET) can be used to download files with multiple sections in parallel.
It has the following options:
- Download speed (in MB/s): This number indicates the speed in MB/s per download over all parallel downloads and chunks.
- Parallel connections per download: When a file is downloaded it is split in sections, this setting indicates how many sections you will download in parallel.
- Connection Timeout: This number indicates the timeout in milliseconds before a download chunk times out. It will retry each chunk 5 times before completely failing.
#### Bezzad Downloader #### Bezzad Downloader
This [downloader](https://github.com/bezzad/Downloader) can be used to download files in parallel and with multiple chunks. This [downloader](https://github.com/bezzad/Downloader) can be used to download files in parallel and with multiple chunks.
@ -148,17 +153,6 @@ Required configuration:
Suggested configuration: Suggested configuration:
- Automatic retry downloads > 3 - Automatic retry downloads > 3
#### Synology Download Station
The Synology Download Station downloader uses an external Download Station server. You will need to set this up yourself.
It has the following options:
- Url: The URL to the Synology DownloadStation. A common URL is `http://127.0.0.1:5000`
- Username: The username to use when connecting to the Synology DownloadStation.
- Password: The password to use when connecting to the Synology DownloadStation.
- Download Path: The root path to download the file on the Synology DownloadStation host. If left empty, the default path configured on your Download Station server will be used.
### Troubleshooting ### Troubleshooting
- If you forgot your logins simply delete the `rdtclient.db` and restart the service. - If you forgot your logins simply delete the `rdtclient.db` and restart the service.
@ -166,9 +160,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 +177,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 +188,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 --prod`.
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`.
@ -242,14 +205,3 @@ It installs .NET 10 and Node 22, forwards ports `4200` and `6500`, and persists
1. To stop: `docker stop rdtclient` 1. To stop: `docker stop rdtclient`
1. To remove: `docker rm rdtclient` 1. To remove: `docker rm rdtclient`
1. Or use `docker-build.bat` 1. Or use `docker-build.bat`
## Misc Install Notes
### Rootless Podman, Linux Host, and CIFS Connections
RDT Client read and write permission tests fail if the CIFS connection is not setup properly, despite permissions working inspection. In the Web GUI, it will report access denied, and in the log file you will see exceptions like this ([dotnet issue](https://github.com/dotnet/runtime/issues/42790)):
```
System.IO.IOException: Permission denied
```
The **nobrl** has to be specified in your CIFS connection - [man page](https://linux.die.net/man/8/mount.cifs).
Example: ```Options=_netdev,credentials=/etc/samba/credentials/600file,rw,uid=SUBUID,gid=SBUGID,nobrl,file_mode=0770,dir_mode=0770,noperm```

View file

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

View file

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

28762
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,24 +1,5 @@
<div class="field"> <div fxLayout.lt-lg="column" fxLayout.gt-sm="row" fxLayoutGap="20px" class="field">
<div class="tabs is-toggle is-fullwidth"> <div fxFlex>
<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,66 +31,30 @@
></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">
<label class="label">Downloader</label> <label class="label">Downloader</label>
<div class="control select is-fullwidth"> <div class="control select is-fullwidth">
<select [(ngModel)]="downloadClient" (ngModelChange)="setFinishAction()"> <select [(ngModel)]="downloadClient" (ngModelChange)="setFinishAction()">
<option [ngValue]="0">Bezzad</option> <option [ngValue]="0">Internal Downloader</option>
<option [ngValue]="1">Aria2c</option> <option [ngValue]="1">Bezzad</option>
<option [ngValue]="2">Symlink Downloader</option> <option [ngValue]="2">Aria2c</option>
<option [ngValue]="3">Synology DownloadStation</option> <option [ngValue]="3">Symlink Downloader</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,11 +66,25 @@
</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>
<div
class="notification is-success is-light"
*ngIf="provider === 'AllDebrid' && availableFiles && availableFiles.length > 0"
>
This torrent is available for immediate download.
</div>
<div
class="notification is-warning is-light"
*ngIf="provider === 'AllDebrid' && availableFiles && availableFiles.length === 0"
>
This torrent is not available for immediate download.
</div>
<div class="field"> <div class="field">
<label class="label">Minimum file size to download</label> <label class="label">Minimum file size to download</label>
<div class="control"> <div class="control">
@ -138,15 +97,11 @@
</div> </div>
</div> </div>
</div> </div>
@if (provider === "AllDebrid") { <p class="help" *ngIf="provider === 'AllDebrid'">
<p class="help"> When downloading with AllDebrid it cannot be guaranteed that only files above this limit will be download as
When downloading with AllDebrid it cannot be guaranteed that only files above this limit will be download as some files are grouped together in 1 large archive.
some files are grouped together in 1 large archive. </p>
</p> <p class="help" *ngIf="downloadAction === 2">This setting does not apply to manually selected files.</p>
}
@if (downloadAction === 2) {
<p class="help">This setting does not apply to manually selected files.</p>
}
</div> </div>
<div class="field"> <div class="field">
@ -162,12 +117,8 @@
Select only the files that are matching this regular expression. Only use this setting OR the Exclude files Select only the files that are matching this regular expression. Only use this setting OR the Exclude files
setting, not both. setting, not both.
</p> </p>
@if (downloadAction === 2) { <p class="help" *ngIf="downloadAction === 2">This setting does not apply to manually selected files.</p>
<p class="help">This setting does not apply to manually selected files.</p> <p class="help is-danger" *ngIf="includeRegexError">{{ includeRegexError }}</p>
}
@if (includeRegexError) {
<p class="help is-danger">{{ includeRegexError }}</p>
}
</div> </div>
<div class="field"> <div class="field">
@ -183,12 +134,8 @@
Ignore files that are matching this regular expression. Only use this setting OR the Include files setting, not Ignore files that are matching this regular expression. Only use this setting OR the Include files setting, not
both. both.
</p> </p>
@if (downloadAction === 2) { <p class="help" *ngIf="downloadAction === 2">This setting does not apply to manually selected files.</p>
<p class="help">This setting does not apply to manually selected files.</p> <p class="help is-danger" *ngIf="excludeRegexError">{{ excludeRegexError }}</p>
}
@if (excludeRegexError) {
<p class="help is-danger">{{ excludeRegexError }}</p>
}
</div> </div>
<div class="field"> <div class="field">
@ -196,46 +143,17 @@
<div class="control select is-fullwidth"> <div class="control select is-fullwidth">
<select [(ngModel)]="finishedAction"> <select [(ngModel)]="finishedAction">
<option [ngValue]="0">Do nothing</option> <option [ngValue]="0">Do nothing</option>
@if (downloadClient !== 2) { <option [ngValue]="1" *ngIf="downloadClient !== 2">Remove torrent from provider and client</option>
<option [ngValue]="1">Remove from provider and client</option> <option [ngValue]="2" *ngIf="downloadClient !== 2">Remove torrent from provider</option>
<option [ngValue]="2">Remove from provider</option> <option [ngValue]="3">Remove torrent from client</option>
}
<option [ngValue]="3">Remove from client</option>
</select> </select>
</div> </div>
</div> </div>
<div class="field">
<label class="label">Finished Action Delay (minutes)</label>
<div class="control is-fullwidth">
<input class="input" type="number" [(ngModel)]="finishedActionDelay" />
</div>
<p class="help">
When a download is finished on the provider, perform this action. Use this setting if you only want to add files
to your debrid provider but not download them to the host.
</p>
</div>
<div class="field"> <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 +163,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 +175,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 +195,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>
@ -294,8 +209,14 @@
<div class="field"> <div class="field">
<div class="control"> <div class="control">
@if (error) { <div class="notification is-danger is-light" *ngIf="error">{{ error }}</div>
<div class="notification is-danger is-light">{{ error }}</div> </div>
} </div>
<div class="field">
<div class="control">
<button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
<span>Add Torrent</span>
</button>
</div> </div>
</div> </div>

View file

@ -3,6 +3,21 @@
width: 24em; width: 24em;
} }
.scroll-container {
width: 100%;
overflow-x: auto;
overflow-y: auto;
max-height: 603px;
}
.is-fullwidth-label {
width: 100%;
}
.is-block {
display: block;
}
.separator { .separator {
display: flex; display: flex;
align-items: center; align-items: center;
@ -28,28 +43,6 @@
margin-left: 0.25em; margin-left: 0.25em;
} }
.category-combo { .strike-through {
position: relative; text-decoration: line-through;
}
.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,44 +1,26 @@
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 { FormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { TorrentService } from '../torrent.service';
@Component({ @Component({
selector: 'app-add-new-torrent', selector: 'app-add-new-torrent',
templateUrl: './add-new-torrent.component.html', templateUrl: './add-new-torrent.component.html',
styleUrls: ['./add-new-torrent.component.scss'], styleUrls: ['./add-new-torrent.component.scss'],
imports: [FormsModule, NgClass],
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;
public finishedActionDelay: number = 0;
public downloadMinSize: number = 0; public downloadMinSize: number = 0;
public includeRegex: string = ''; public includeRegex: string = '';
public excludeRegex: string = ''; public excludeRegex: string = '';
@ -59,93 +41,38 @@ 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) { ) {}
this._category = value;
this.updateFilteredCategories();
}
ngOnInit(): void { ngOnInit(): void {
this.activatedRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { this.settingsService.get().subscribe((settings) => {
if (params['type'] === 'nzb') { const providerSetting = settings.first((m) => m.key === 'Provider:Provider');
this.type = 'nzb'; this.provider = providerSetting.enumValues[providerSetting.value as number];
} else if (params['type'] === 'torrent') { this.downloadClient = settings.first((m) => m.key === 'DownloadClient:Client')?.value as number;
this.type = 'torrent';
}
if (params['magnet']) { this.category = settings.first((m) => m.key === 'Gui:Default:Category')?.value as string;
this.magnetLink = decodeURIComponent(params['magnet']); this.hostDownloadAction = this.downloadAction = settings.first((m) => m.key === 'Gui:Default:HostDownloadAction')
this.type = 'torrent'; ?.value as number;
} this.downloadAction =
if (params['nzb']) { settings.first((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.nzbLink = decodeURIComponent(params['nzb']); this.finishedAction = settings.first((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.type = 'nzb'; this.downloadMinSize = settings.first((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
} this.includeRegex = settings.first((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
this.excludeRegex = settings.first((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
this.torrentRetryAttempts = settings.first((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
this.downloadRetryAttempts = settings.first((m) => m.key === 'Gui:Default:DownloadRetryAttempts')
?.value as number;
this.torrentDeleteOnError = settings.first((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
this.torrentLifetime = settings.first((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
this.priority = settings.first((m) => m.key === 'Gui:Default:Priority')?.value as number;
this.setFinishAction();
}); });
this.settingsService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((settings) => {
const providerSetting = settings.find((m) => m.key === 'Provider:Provider');
this.provider = providerSetting.enumValues[providerSetting.value as number];
this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.value as number;
this.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string;
const categoriesSetting = settings.find((m) => m.key === 'General:Categories')?.value as string;
this.categories = (categoriesSetting ?? '')
.split(',')
.map((c) => c.trim())
.filter((c) => c.length > 0)
.filter((c, i, arr) => arr.findIndex((a) => a.toLowerCase() === c.toLowerCase()) === i);
const matchedCategory = this.categories.find((c) => c.toLowerCase() === (this.category ?? '').toLowerCase());
if (matchedCategory) {
this.category = matchedCategory;
} else {
this.updateFilteredCategories();
}
this.hostDownloadAction = this.downloadAction = settings.find((m) => m.key === 'Gui:Default:HostDownloadAction')
?.value as number;
this.downloadAction =
settings.find((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.finishedAction = settings.find((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.finishedActionDelay = settings.find((m) => m.key == 'Gui:Default:FinishedActionDelay')?.value as number;
this.downloadMinSize = settings.find((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
this.includeRegex = settings.find((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
this.excludeRegex = settings.find((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
this.torrentRetryAttempts = settings.find((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
this.downloadRetryAttempts = settings.find((m) => m.key === 'Gui:Default:DownloadRetryAttempts')
?.value as number;
this.torrentDeleteOnError = settings.find((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
this.torrentLifetime = settings.find((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
this.priority = settings.find((m) => m.key === 'Gui:Default:Priority')?.value as number;
this.setFinishAction();
});
}
private updateFilteredCategories(): void {
if (!this.category) {
this.filteredCategories = this.categories;
return;
}
const search = this.category.toLowerCase();
this.filteredCategories = this.categories.filter((value) => value.toLowerCase().includes(search));
}
public selectCategory(cat: string): void {
this.category = cat;
this.categoryDropdownOpen = false;
}
public onCategoryBlur(): void {
setTimeout(() => (this.categoryDropdownOpen = false), 150);
} }
public setFinishAction() { public setFinishAction() {
@ -158,16 +85,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;
} }
@ -180,6 +101,25 @@ export class AddNewTorrentComponent implements OnInit {
this.checkFiles(); this.checkFiles();
} }
public downloadFileChecked(file: string): void {
this.downloadFiles[file] = !this.downloadFiles[file];
this.allSelected = true;
this.availableFiles.forEach((file) => {
if (!this.downloadFiles[file.filename]) {
this.allSelected = false;
}
});
}
public downloadFileCheckedAll(): void {
this.allSelected = !this.allSelected;
this.availableFiles.forEach((file) => {
this.downloadFiles[file.filename] = this.allSelected;
});
}
public ok(): void { public ok(): void {
this.saving = true; this.saving = true;
this.error = null; this.error = null;
@ -188,7 +128,7 @@ export class AddNewTorrentComponent implements OnInit {
if (this.downloadAction === 2) { if (this.downloadAction === 2) {
const selectedFiles = []; const selectedFiles = [];
for (const filePath in this.downloadFiles) { for (let filePath in this.downloadFiles) {
if (this.downloadFiles[filePath] === true) { if (this.downloadFiles[filePath] === true) {
selectedFiles.push(filePath); selectedFiles.push(filePath);
} }
@ -207,7 +147,6 @@ export class AddNewTorrentComponent implements OnInit {
torrent.hostDownloadAction = this.hostDownloadAction; torrent.hostDownloadAction = this.hostDownloadAction;
torrent.downloadAction = this.downloadAction; torrent.downloadAction = this.downloadAction;
torrent.finishedAction = this.finishedAction; torrent.finishedAction = this.finishedAction;
torrent.finishedActionDelay = this.finishedActionDelay;
torrent.downloadMinSize = this.downloadMinSize; torrent.downloadMinSize = this.downloadMinSize;
torrent.includeRegex = this.includeRegex; torrent.includeRegex = this.includeRegex;
torrent.excludeRegex = this.excludeRegex; torrent.excludeRegex = this.excludeRegex;
@ -219,49 +158,29 @@ 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) { () => {
this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe({ this.router.navigate(['/']);
next: () => this.router.navigate(['/']), },
error: (err) => { (err) => {
this.error = err.error; this.error = err.error;
this.saving = false; this.saving = false;
}, },
}); );
} else if (this.selectedFile) { } else if (this.selectedFile) {
this.torrentService.uploadFile(this.selectedFile, torrent).subscribe({ this.torrentService.uploadFile(this.selectedFile, torrent).subscribe(
next: () => this.router.navigate(['/']), () => {
error: (err) => { this.router.navigate(['/']);
this.error = err.error; },
this.saving = false; (err) => {
}, 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 +191,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;
} }
@ -286,8 +202,8 @@ export class AddNewTorrentComponent implements OnInit {
this.allSelected = true; this.allSelected = true;
if (this.magnetLink) { if (this.magnetLink) {
this.torrentService.checkFilesMagnet(this.magnetLink).subscribe({ this.torrentService.checkFilesMagnet(this.magnetLink).subscribe(
next: (result) => { (result) => {
this.saving = false; this.saving = false;
this.availableFiles = result; this.availableFiles = result;
this.currentTorrentFile = this.magnetLink; this.currentTorrentFile = this.magnetLink;
@ -295,30 +211,42 @@ export class AddNewTorrentComponent implements OnInit {
this.downloadFiles[file.filename] = true; this.downloadFiles[file.filename] = true;
}); });
}, },
error: (err) => { (err) => {
this.error = err.error; this.error = err.error;
this.saving = false; this.saving = false;
}, },
}); );
} else if (this.selectedFile) { } else if (this.selectedFile) {
this.torrentService.checkFiles(this.selectedFile).subscribe({ this.torrentService.checkFiles(this.selectedFile).subscribe(
next: (result) => { (result) => {
this.saving = false; this.saving = false;
this.availableFiles = result; this.availableFiles = result;
result.forEach((file) => { result.forEach((file) => {
this.downloadFiles[file.filename] = true; this.downloadFiles[file.filename] = true;
}); });
}, },
error: (err) => { (err) => {
this.error = err.error; this.error = err.error;
this.saving = false; this.saving = false;
}, },
}); );
} else { } else {
this.saving = false; this.saving = false;
} }
} }
public isRegexExcluded(file: TorrentFileAvailability): boolean {
if (this.regexSelected == null) {
return false;
}
if (this.regexSelected.find((m) => m.filename === file.filename) == null) {
return true;
}
return false;
}
public verifyRegex(): void { public verifyRegex(): void {
this.includeRegexError = null; this.includeRegexError = null;
this.excludeRegexError = null; this.excludeRegexError = null;

View file

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

View file

@ -1,10 +1,8 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
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],
}) })
export class AppComponent {} export class AppComponent {}

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -1,77 +1,53 @@
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 { 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 { filter } from 'rxjs';
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss'], styleUrls: ['./navbar.component.scss'],
imports: [RouterLink, NgClass, DatePipe],
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;
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), ) {}
)
.subscribe(() => {
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': }
this.providerLink = 'https://debrid-link.com/'; });
break;
}
});
this.settingsService
.getVersion()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
this.version = result.version;
});
} }
public logout(): void { public logout(): void {
this.authService.logout().subscribe({ next: () => this.router.navigate(['/login']), error: console.error }); this.authService.logout().subscribe(
() => {
this.router.navigate(['/login']);
},
(err) => {},
);
} }
} }

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,13 @@
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 { FormsModule } from '@angular/forms';
@Component({ @Component({
selector: 'app-setup', selector: 'app-setup',
templateUrl: './setup.component.html', templateUrl: './setup.component.html',
styleUrls: ['./setup.component.scss'], styleUrls: ['./setup.component.scss'],
imports: [FormsModule, NgClass],
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,33 +18,35 @@ 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;
this.authService.create(this.userName, this.password).subscribe({ this.authService.create(this.userName, this.password).subscribe(
next: () => { () => {
this.step = 2; this.step = 2;
this.working = false; this.working = false;
}, },
error: (err) => { (err) => {
this.working = false; this.working = false;
this.error = err.error; this.error = err.error;
}, }
}); );
} }
public setToken(): void { public setToken(): void {
this.authService.setupProvider(this.provider, this.token).subscribe({ this.authService.setupProvider(this.provider, this.token).subscribe(
next: () => { () => {
this.step = 3; this.step = 3;
this.working = false; this.working = false;
}, },
error: (err: any) => { (err: any) => {
this.working = false; this.working = false;
this.error = err.error; this.error = err.error;
}, }
}); );
} }
public close(): void { public close(): void {

View file

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

@ -1,132 +1,108 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import { FileSizePipe } from 'ngx-filesize';
import { RealDebridStatus, Torrent } from './models/torrent.model'; import { RealDebridStatus, Torrent } from './models/torrent.model';
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.all((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.where((m) => m.downloadStarted && !m.downloadFinished && m.bytesDone > 0);
downloadedCount += 1; const downloaded = torrent.downloads.where((m) => m.downloadFinished != null);
if (downloading.length > 0) {
const bytesDone = downloading.sum((m) => m.bytesDone);
const bytesTotal = downloading.sum((m) => m.bytesTotal);
let progress = (bytesDone / bytesTotal) * 100;
if (isNaN(progress)) {
progress = 0;
}
let allSpeeds = downloading.sum((m) => m.speed);
let speed: string | string[] = '0';
speed = this.pipe.transform(allSpeeds, 'filesize');
return `Downloading file ${downloading.length + downloaded.length}/${torrent.downloads.length
} (${progress.toFixed(2)}% - ${speed}/s)`;
} }
if (download.downloadStarted && !download.downloadFinished && download.bytesDone > 0) { const unpacking = torrent.downloads.where((m) => m.unpackingStarted && !m.unpackingFinished && m.bytesDone > 0);
downloadingCount += 1; const unpacked = torrent.downloads.where((m) => m.unpackingFinished != null);
downloadingBytesDone += download.bytesDone;
downloadingBytesTotal += download.bytesTotal; if (unpacking.length > 0) {
downloadingSpeed += download.speed; const bytesDone = unpacking.sum((m) => m.bytesDone);
const bytesTotal = unpacking.sum((m) => m.bytesTotal);
let progress = (bytesDone / bytesTotal) * 100;
if (isNaN(progress)) {
progress = 0;
}
return `Extracting file ${unpacking.length + unpacked.length}/${torrent.downloads.length} (${progress.toFixed(
2,
)}%)`;
} }
if (download.unpackingFinished != null) { const queuedForUnpacking = torrent.downloads.where((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.where((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.Downloading:
const speed = fileSizePipe.transform(downloadingSpeed, 'filesize') as string; if (torrent.rdSeeders < 1) {
return `Torrent stalled`
return `Downloading file ${downloadingCount + downloadedCount}/${downloads.length} (${progress.toFixed(2)}% - ${speed}/s)`; }
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

@ -1,137 +1,92 @@
@if (error && error.length > 0) { <div class="notification is-danger is-light" *ngIf="error && error.length > 0">
<div class="notification is-danger is-light"> An error has occured: {{ error }}<br />
An error has occurred: {{ error }}<br /> 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>
<tr> <th>
<th> <input
<input type="checkbox"
type="checkbox" (click)="toggleSelectAll($event)"
(click)="toggleDeleteSelectAll($event)" [checked]="selectedTorrents.length > 0 && selectedTorrents.length === torrents.length"
[checked]="selectedTorrents.length > 0 && selectedTorrents.length === torrents.length" />
/> </th>
</th> <th (click)="sort('rdName')">Name</th>
<th (click)="sort('rdName')">Name</th> <th (click)="sort('category')">Category</th>
<th (click)="sort('category')">Category</th> <th (click)="sort('priority')">Priority</th>
<th (click)="sort('priority')">Priority</th> <th (click)="sort('rdSeeders')">Seeders</th>
<th (click)="sort('rdSeeders')">Seeders</th> <th (click)="sort('files.length')">Files</th>
<th (click)="sort('files.length')">Files</th> <th (click)="sort('downloads.length')">Downloads</th>
<th (click)="sort('downloads.length')">Downloads</th> <th (click)="sort('rdSize')">Size</th>
<th (click)="sort('rdSize')">Size</th> <th (click)="sort('status')">Status</th>
<th (click)="sort('added')">Requested</th> </tr>
<th (click)="sort('status')">Status</th> </thead>
</tr> <tbody>
</thead> <tr *ngFor="let torrent of torrents | sort: sortProperty : sortDirection; trackBy: trackByMethod">
<tbody> <td>
@for (torrent of sortedTorrents; track torrent.torrentId) { <input
<tr> type="checkbox"
<td> (click)="toggleSelect(torrent.torrentId)"
<input type="checkbox" (click)="toggleSelect(torrent.torrentId)" [checked]="isSelected(torrent.torrentId)" /> [checked]="selectedTorrents.contains(torrent.torrentId)"
</td> />
<td (click)="openTorrent(torrent.torrentId)" class="break-all"> </td>
{{ torrent.rdName }} <td (click)="openTorrent(torrent.torrentId)">
</td> {{ torrent.rdName }}
<td> </td>
{{ torrent.category }} <td>
</td> {{ torrent.category }}
<td> </td>
{{ torrent.priority }} <td>
</td> {{ torrent.priority }}
<td> </td>
{{ torrent.rdSeeders }} <td>
</td> {{ torrent.rdSeeders }}
<td> </td>
{{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }} <td>
</td> {{ torrent.files.length | number }}
<td> </td>
{{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }} <td>
</td> {{ torrent.downloads.length | number }}
<td> </td>
{{ torrent.rdSize | filesize }} <td>
</td> {{ torrent.rdSize | filesize }}
<td> </td>
{{ torrent.added | date: "medium" }} <td>
</td> {{ torrent | status }}
<td> </td>
{{ torrent.statusText ?? (torrent | status) }} </tr>
</td> </tbody>
</tr> </table>
}
</tbody>
</table>
} @else {
<div class="mobile-cards">
@for (torrent of sortedTorrents; track torrent.torrentId) {
<div class="box mobile-card" [class.is-selected]="isSelected(torrent.torrentId)">
<div class="mobile-card-checkbox">
<input
type="checkbox"
(click)="toggleSelect(torrent.torrentId)"
[checked]="isSelected(torrent.torrentId)"
/>
</div>
<div class="mobile-card-body" (click)="openTorrent(torrent.torrentId)">
<p class="mobile-card-name">{{ torrent.rdName }}</p>
<div class="tags mb-2">
<span class="tag is-info is-light">{{ torrent.statusText ?? (torrent | status) }}</span>
@if (torrent.category) {
<span class="tag is-light">{{ torrent.category }}</span>
}
</div>
<div class="mobile-card-details">
<span class="mobile-card-detail"
><span class="detail-label">Size</span> {{ torrent.rdSize | filesize }}</span
>
<span class="mobile-card-detail"><span class="detail-label">Seeders</span> {{ torrent.rdSeeders }}</span>
<span class="mobile-card-detail"
><span class="detail-label">Files</span> {{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }}</span
>
<span class="mobile-card-detail"
><span class="detail-label">Downloads</span> {{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}</span
>
</div>
</div>
</div>
}
</div>
}
<div class="flex-container"> <div fxLayout.lt-lg="column" fxLayout.gt-sm="row" fxLayoutGap="20px">
@if (torrents.length > 0) { <button
<button class="button is-danger" (click)="showDeleteModal()" [disabled]="selectedTorrents.length === 0"> class="button is-danger"
Delete Selected (click)="showDeleteModal()"
</button> [disabled]="selectedTorrents.length === 0"
<button class="button is-primary" (click)="showRetryModal()" [disabled]="selectedTorrents.length === 0"> *ngIf="torrents.length > 0"
Retry Selected >
</button> Delete Selected
<button class="button is-primary" (click)="changeSettingsModal()" [disabled]="selectedTorrents.length === 0"> </button>
Change Settings
</button> <button
} class="button is-primary"
(click)="showRetryModal()"
[disabled]="selectedTorrents.length === 0"
*ngIf="torrents.length > 0"
>
Retry Selected
</button>
<button
class="button is-primary"
(click)="changeSettingsModal()"
[disabled]="selectedTorrents.length === 0"
*ngIf="torrents.length > 0"
>
Change Settings
</button>
</div> </div>
</div> </div>
@ -148,33 +103,27 @@
<label class="label"></label> <label class="label"></label>
<div class="control"> <div class="control">
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteData" (change)="updateDeleteSelectAll()" /> <input type="checkbox" [(ngModel)]="deleteData" />
Delete Torrents from client Delete Torrents from client
</label> </label>
<br /> <br />
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteRdTorrent" (change)="updateDeleteSelectAll()" /> <input type="checkbox" [(ngModel)]="deleteRdTorrent" />
Delete Torrents from provider Delete Torrents from provider
</label> </label>
<br /> <br />
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteLocalFiles" (change)="updateDeleteSelectAll()" /> <input type="checkbox" [(ngModel)]="deleteLocalFiles" />
Delete local files Delete local files
</label> </label>
<br />
<br />
<label class="checkbox">
<input type="checkbox" [(ngModel)]="deleteSelectAll" (change)="toggleDeleteSelectAllOptions()" />
Select All
</label>
</div> </div>
</div> </div>
<div class="notification is-primary"> <div class="notification is-primary">
Deleting a torrent from Real-Debrid will automatically delete it here too. Deleting a torrent from Real-Debrid will automatically delete it here too.
</div> </div>
@if (deleteError?.length > 0) { <div class="notification is-danger is-light" *ngIf="deleteError?.length > 0">
<div class="notification is-danger is-light">Error deleting torrent: {{ deleteError }}</div> Error deleting torrent: {{ deleteError }}
} </div>
</section> </section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
<button <button
@ -205,9 +154,9 @@
This action will delete all the torrent data + all local downloads. Then it will re-add the original magnet link This action will delete all the torrent data + all local downloads. Then it will re-add the original magnet link
or torrent file to the debrid provider. or torrent file to the debrid provider.
</p> </p>
@if (retryError?.length > 0) { <div class="notification is-danger is-light" *ngIf="retryError?.length > 0">
<div class="notification is-danger is-light">Error retrying torrent: {{ retryError }}</div> Error retrying torrent: {{ retryError }}
} </div>
</section> </section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
<button <button
@ -238,10 +187,10 @@
<div class="control select is-fullwidth"> <div class="control select is-fullwidth">
<select [(ngModel)]="updateSettingsDownloadClient"> <select [(ngModel)]="updateSettingsDownloadClient">
<option [ngValue]="null"></option> <option [ngValue]="null"></option>
<option [ngValue]="0">Bezzad</option> <option [ngValue]="0">Internal Downloader</option>
<option [ngValue]="1">Aria2c</option> <option [ngValue]="1">Bezzad</option>
<option [ngValue]="2">Symlink Downloader</option> <option [ngValue]="2">Aria2c</option>
<option [ngValue]="3">Synology DownloadStation</option> <option [ngValue]="3">Symlink Downloader</option>
</select> </select>
</div> </div>
<p class="help"> <p class="help">
@ -336,9 +285,9 @@
</p> </p>
</div> </div>
@if (changeSettingsError?.length > 0) { <div class="notification is-danger is-light" *ngIf="changeSettingsError?.length > 0">
<div class="notification is-danger is-light">Error changing settings: {{ changeSettingsError }}</div> Error changing settings: {{ changeSettingsError }}
} </div>
</section> </section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
<button <button

View file

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

View file

@ -1,41 +1,24 @@
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 { NgClass, DecimalPipe, DatePipe } from '@angular/common';
import { getTorrentStatus, TorrentStatusPipe } from '../torrent-status.pipe';
import { SortDirection, getSortFieldValue, sortItems } from '../sort.pipe';
import { FileSizePipe } from '../filesize.pipe';
@Component({ @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],
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;
public deleting: boolean; public deleting: boolean;
public deleteSelectAll: boolean;
public deleteData: boolean; public deleteData: boolean;
public deleteRdTorrent: boolean; public deleteRdTorrent: boolean;
public deleteLocalFiles: boolean; public deleteLocalFiles: boolean;
@ -57,120 +40,57 @@ 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; (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) { (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 trackByMethod(index: number, el: Torrent): string {
const checked = (event.target as HTMLInputElement).checked; return el.torrentId;
}
this.selectedTorrentIds = checked ? new Set(this.torrents.map((torrent) => torrent.torrentId)) : new Set<string>(); public toggleSelectAll(event: any) {
this.syncSelectedTorrents(); this.selectedTorrents = [];
if (event.target.checked) {
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 {
@ -189,7 +109,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
public deleteOk(): void { public deleteOk(): void {
this.deleting = true; this.deleting = true;
const calls: Observable<void>[] = []; let calls: Observable<void>[] = [];
this.selectedTorrents.forEach((torrentId) => { this.selectedTorrents.forEach((torrentId) => {
calls.push(this.torrentService.delete(torrentId, this.deleteData, this.deleteRdTorrent, this.deleteLocalFiles)); calls.push(this.torrentService.delete(torrentId, this.deleteData, this.deleteRdTorrent, this.deleteLocalFiles));
@ -200,7 +120,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;
@ -222,7 +142,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
public retryOk(): void { public retryOk(): void {
this.retrying = true; this.retrying = true;
const calls: Observable<void>[] = []; let calls: Observable<void>[] = [];
this.selectedTorrents.forEach((torrentId) => { this.selectedTorrents.forEach((torrentId) => {
calls.push(this.torrentService.retry(torrentId)); calls.push(this.torrentService.retry(torrentId));
@ -233,7 +153,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,40 +165,30 @@ 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.where((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
this.updateSettingsDownloadClient = selectedTorrents.every( this.updateSettingsDownloadClient =
(m, _, arr) => m.downloadClient === arr[0].downloadClient, selectedTorrents.distinctBy((m) => m.downloadClient).count() == 1 ? selectedTorrents[0].downloadClient : null;
) this.updateSettingsHostDownloadAction =
? selectedTorrents[0].downloadClient selectedTorrents.distinctBy((m) => m.hostDownloadAction).count() == 1
: null; ? selectedTorrents[0].hostDownloadAction
this.updateSettingsHostDownloadAction = selectedTorrents.every( : null;
(m, _, arr) => m.hostDownloadAction === arr[0].hostDownloadAction, this.updateSettingsCategory =
) selectedTorrents.distinctBy((m) => m.category).count() == 1 ? selectedTorrents[0].category : null;
? selectedTorrents[0].hostDownloadAction this.updateSettingsPriority =
: null; selectedTorrents.distinctBy((m) => m.priority).count() == 1 ? selectedTorrents[0].priority : null;
this.updateSettingsCategory = selectedTorrents.every((m, _, arr) => m.category === arr[0].category) this.updateSettingsDownloadRetryAttempts =
? selectedTorrents[0].category selectedTorrents.distinctBy((m) => m.downloadRetryAttempts).count() == 1
: null; ? selectedTorrents[0].downloadRetryAttempts
this.updateSettingsPriority = selectedTorrents.every((m, _, arr) => m.priority === arr[0].priority) : null;
? selectedTorrents[0].priority this.updateSettingsTorrentRetryAttempts =
: null; selectedTorrents.distinctBy((m) => m.torrentRetryAttempts).count() == 1
this.updateSettingsDownloadRetryAttempts = selectedTorrents.every( ? selectedTorrents[0].torrentRetryAttempts
(m, _, arr) => m.downloadRetryAttempts === arr[0].downloadRetryAttempts, : null;
) this.updateSettingsDeleteOnError =
? selectedTorrents[0].downloadRetryAttempts selectedTorrents.distinctBy((m) => m.deleteOnError).count() == 1 ? selectedTorrents[0].deleteOnError : null;
: null; this.updateSettingsTorrentLifetime =
this.updateSettingsTorrentRetryAttempts = selectedTorrents.every( selectedTorrents.distinctBy((m) => m.lifetime).count() == 1 ? selectedTorrents[0].lifetime : null;
(m, _, arr) => m.torrentRetryAttempts === arr[0].torrentRetryAttempts,
)
? selectedTorrents[0].torrentRetryAttempts
: null;
this.updateSettingsDeleteOnError = selectedTorrents.every((m, _, arr) => m.deleteOnError === arr[0].deleteOnError)
? selectedTorrents[0].deleteOnError
: null;
this.updateSettingsTorrentLifetime = selectedTorrents.every((m, _, arr) => m.lifetime === arr[0].lifetime)
? selectedTorrents[0].lifetime
: null;
this.isChangeSettingsModalActive = true; this.isChangeSettingsModalActive = true;
} }
@ -290,9 +200,9 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
public changeSettingsOk(): void { public changeSettingsOk(): void {
this.changingSettings = true; this.changingSettings = true;
const calls: Observable<void>[] = []; let calls: Observable<void>[] = [];
const selectedTorrents = this.getSelectedTorrentModels(); const selectedTorrents = this.torrents.where((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
selectedTorrents.forEach((torrent) => { selectedTorrents.forEach((torrent) => {
if (this.updateSettingsDownloadClient != null) { if (this.updateSettingsDownloadClient != null) {
@ -308,7 +218,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 +238,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;
@ -336,65 +246,4 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
}, },
}); });
} }
toggleDeleteSelectAllOptions() {
this.deleteData = this.deleteSelectAll;
this.deleteRdTorrent = this.deleteSelectAll;
this.deleteLocalFiles = this.deleteSelectAll;
}
updateDeleteSelectAll() {
this.deleteSelectAll = this.deleteData && this.deleteRdTorrent && this.deleteLocalFiles;
}
ngOnDestroy(): void {
this.mobileQuery?.removeEventListener('change', this.mobileQueryListener);
}
private setTorrents(torrents: Torrent[]): void {
this.torrents = torrents;
this.pruneSelectedTorrents();
this.applySorting();
}
private applySorting(): void {
this.sortedTorrents = sortItems(this.torrents, this.sortProperty, this.sortDirection, (torrent, field) => {
switch (field) {
case 'files.length':
return torrent.filesCount ?? torrent.files?.length ?? 0;
case 'downloads.length':
return torrent.downloadsCount ?? torrent.downloads?.length ?? 0;
case 'status':
return torrent.statusText ?? getTorrentStatus(torrent);
default:
return getSortFieldValue(torrent, field);
}
});
}
private getSelectedTorrentModels(): Torrent[] {
return this.torrents.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId));
}
private pruneSelectedTorrents(): void {
const torrentIds = new Set(this.torrents.map((torrent) => torrent.torrentId));
for (const torrentId of this.selectedTorrentIds) {
if (!torrentIds.has(torrentId)) {
this.selectedTorrentIds.delete(torrentId);
}
}
this.syncSelectedTorrents();
}
private clearSelectedTorrents(): void {
this.selectedTorrentIds.clear();
this.syncSelectedTorrents();
}
private syncSelectedTorrents(): void {
this.selectedTorrents = this.torrents
.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId))
.map((torrent) => torrent.torrentId);
}
} }

View file

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

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

View file

@ -8,38 +8,6 @@ table {
} }
} }
.break-all { .fa-download {
word-break: break-all; margin-left :12px;
}
.flex-container {
display: flex;
flex: 1 1 0;
gap: 20px;
flex-direction: row;
@media screen and (max-width: 1279px) {
flex-direction: column;
}
}
@media screen and (max-width: 768px) {
.action-buttons {
.control {
width: 100%;
.button {
width: 100%;
}
}
}
.tabs ul {
flex-wrap: wrap;
li {
flex-grow: 1;
text-align: center;
}
}
} }

View file

@ -1,41 +1,15 @@
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';
import { TorrentService } from '../torrent.service'; import { TorrentService } from '../torrent.service';
import { NgClass, DatePipe } from '@angular/common';
import { CdkCopyToClipboard } from '@angular/cdk/clipboard';
import { FormsModule } from '@angular/forms';
import { TorrentStatusPipe } from '../torrent-status.pipe';
import { DownloadStatusPipe } from '../download-status.pipe';
import { DecodeURIPipe } from '../decode-uri.pipe';
import { FileSizePipe } from '../filesize.pipe';
import { EMPTY, distinctUntilChanged, map, switchMap, tap, catchError } from 'rxjs';
@Component({ @Component({
selector: 'app-torrent', selector: 'app-torrent',
templateUrl: './torrent.component.html', templateUrl: './torrent.component.html',
styleUrls: ['./torrent.component.scss'], styleUrls: ['./torrent.component.scss'],
imports: [
NgClass,
CdkCopyToClipboard,
FormsModule,
DatePipe,
TorrentStatusPipe,
DownloadStatusPipe,
DecodeURIPipe,
FileSizePipe,
],
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;
@ -47,7 +21,6 @@ export class TorrentComponent implements OnInit {
public isDeleteModalActive: boolean; public isDeleteModalActive: boolean;
public deleteError: string; public deleteError: string;
public deleting: boolean; public deleting: boolean;
public deleteSelectAll: boolean;
public deleteData: boolean; public deleteData: boolean;
public deleteRdTorrent: boolean; public deleteRdTorrent: boolean;
public deleteLocalFiles: boolean; public deleteLocalFiles: boolean;
@ -74,68 +47,48 @@ export class TorrentComponent implements OnInit {
public updating: boolean; public updating: boolean;
ngOnInit(): void { constructor(private activatedRoute: ActivatedRoute, private router: Router, private torrentService: TorrentService) {}
this.activatedRoute.params
.pipe(
map((params) => params['id'] as string),
distinctUntilChanged(),
tap((torrentId) => {
this.currentTorrentId = torrentId;
}),
switchMap((torrentId) =>
this.torrentService.get(torrentId).pipe(
catchError(() => {
this.router.navigate(['/']);
return EMPTY;
}),
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((torrent) => {
this.torrent = torrent;
this.currentTorrentId = torrent.torrentId;
});
this.torrentService.update$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => { ngOnInit(): void {
this.update(result); this.activatedRoute.params.subscribe((params) => {
const torrentId = params['id'];
this.torrentService.get(torrentId).subscribe(
(torrent) => {
this.torrent = torrent;
this.torrentService.update$.subscribe((result) => {
this.update(result);
});
},
() => {
this.router.navigate(['/']);
}
);
}); });
} }
public update(torrents: Torrent[]): void { public update(torrents: Torrent[]): void {
const torrentId = this.currentTorrentId ?? this.torrent?.torrentId; const updatedTorrent = torrents.firstOrDefault((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)
.split('')
.map(function (c) {
return c.charCodeAt(0);
})
);
for (let index = 0; index < binaryString.length; index += 1) { var blob = new Blob([byteArray], { type: 'application/x-bittorrent' });
byteArray[index] = binaryString.charCodeAt(index);
}
const blob = new Blob([byteArray], { type: 'application/x-bittorrent' });
saveAs(blob, `${this.torrent.rdName}.torrent`); saveAs(blob, `${this.torrent.rdName}.torrent`);
} }
@ -157,18 +110,18 @@ export class TorrentComponent implements OnInit {
this.torrentService this.torrentService
.delete(this.torrent.torrentId, this.deleteData, this.deleteRdTorrent, this.deleteLocalFiles) .delete(this.torrent.torrentId, this.deleteData, this.deleteRdTorrent, this.deleteLocalFiles)
.subscribe({ .subscribe(
next: () => { () => {
this.isDeleteModalActive = false; this.isDeleteModalActive = false;
this.deleting = false; this.deleting = false;
this.router.navigate(['/']); this.router.navigate(['/']);
}, },
error: (err) => { (err) => {
this.deleteError = err.error; this.deleteError = err.error;
this.deleting = false; this.deleting = false;
}, }
}); );
} }
public showRetryModal(): void { public showRetryModal(): void {
@ -184,18 +137,18 @@ export class TorrentComponent implements OnInit {
public retryOk(): void { public retryOk(): void {
this.retrying = true; this.retrying = true;
this.torrentService.retry(this.torrent.torrentId).subscribe({ this.torrentService.retry(this.torrent.torrentId).subscribe(
next: () => { () => {
this.isRetryModalActive = false; this.isRetryModalActive = false;
this.retrying = false; this.retrying = false;
this.router.navigate(['/']); this.router.navigate(['/']);
}, },
error: (err) => { (err) => {
this.retryError = err.error; this.retryError = err.error;
this.retrying = false; this.retrying = false;
}, }
}); );
} }
public showDownloadRetryModal(downloadId: string): void { public showDownloadRetryModal(downloadId: string): void {
@ -212,16 +165,16 @@ export class TorrentComponent implements OnInit {
public downloadRetryOk(): void { public downloadRetryOk(): void {
this.downloadRetrying = true; this.downloadRetrying = true;
this.torrentService.retryDownload(this.downloadRetryId).subscribe({ this.torrentService.retryDownload(this.downloadRetryId).subscribe(
next: () => { () => {
this.isDownloadRetryModalActive = false; this.isDownloadRetryModalActive = false;
this.downloadRetrying = false; this.downloadRetrying = false;
}, },
error: (err) => { (err) => {
this.downloadRetryError = err.error; this.downloadRetryError = err.error;
this.downloadRetrying = false; this.downloadRetrying = false;
}, }
}); );
} }
public showUpdateSettingsModal(): void { public showUpdateSettingsModal(): void {
@ -253,24 +206,15 @@ export class TorrentComponent implements OnInit {
this.torrent.deleteOnError = this.updateSettingsDeleteOnError; this.torrent.deleteOnError = this.updateSettingsDeleteOnError;
this.torrent.lifetime = this.updateSettingsTorrentLifetime; this.torrent.lifetime = this.updateSettingsTorrentLifetime;
this.torrentService.update(this.torrent).subscribe({ this.torrentService.update(this.torrent).subscribe(
next: () => { () => {
this.isUpdateSettingsModalActive = false; this.isUpdateSettingsModalActive = false;
this.updating = false; this.updating = false;
}, },
error: () => { () => {
this.isUpdateSettingsModalActive = false; this.isUpdateSettingsModalActive = false;
this.updating = false; this.updating = false;
}, }
}); );
}
toggleDeleteSelectAllOptions() {
this.deleteData = this.deleteSelectAll;
this.deleteRdTorrent = this.deleteSelectAll;
this.deleteLocalFiles = this.deleteSelectAll;
}
updateDeleteSelectAll() {
this.deleteSelectAll = this.deleteData && this.deleteRdTorrent && this.deleteLocalFiles;
} }
} }

View file

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

View file

@ -1,28 +1,13 @@
import { enableProdMode, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { FileSizePipe } from './app/filesize.pipe';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { AuthInterceptor } from './app/auth.interceptor';
import { APP_BASE_HREF } from '@angular/common';
import { BrowserModule, bootstrapApplication } from '@angular/platform-browser';
import { AppRoutingModule } from './app/app-routing.module';
import { FormsModule } from '@angular/forms';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { AppComponent } from './app/app.component';
if (environment.production) { if (environment.production) {
enableProdMode(); enableProdMode();
} }
bootstrapApplication(AppComponent, { platformBrowserDynamic()
providers: [ .bootstrapModule(AppModule)
provideZoneChangeDetection(), .catch((err) => console.error(err));
importProvidersFrom(BrowserModule, AppRoutingModule, FormsModule, ClipboardModule),
FileSizePipe,
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: APP_BASE_HREF, useValue: (window as any)['_app_base'] || '/' },
provideHttpClient(withInterceptorsFromDi()),
],
}).catch((err) => console.error(err));

View file

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

View file

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

0
tools/docker-build-dev.ps1 → docker-build-dev.ps1 Normal file → Executable file
View file

0
tools/docker-build.ps1 → docker-build.ps1 Normal file → Executable file
View file

0
tools/docker-publish.ps1 → docker-publish.ps1 Normal file → Executable file
View file

View file

@ -3,17 +3,21 @@ $utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False
$version = (Get-Content "package.json" | ConvertFrom-Json).version $version = (Get-Content "package.json" | ConvertFrom-Json).version
$csProj = "$pwd\server\RdtClient.Web\RdtClient.Web.csproj" $csProj = "$pwd\server\RdtClient.Web\RdtClient.Web.csproj"
$navbar = "$pwd\client\src\app\navbar\navbar.component.html";
$newCsProj = (Get-Content $csProj) -replace '<Version>.*?<\/Version>', "<Version>$version</Version>" $newCsProj = (Get-Content $csProj) -replace '<Version>.*?<\/Version>', "<Version>$version</Version>"
[System.IO.File]::WriteAllLines($csProj, $newCsProj, $utf8NoBomEncoding) [System.IO.File]::WriteAllLines($csProj, $newCsProj, $utf8NoBomEncoding)
$newNavbar = (Get-Content $navbar) -replace 'Version .*?<', "Version $version<"
[System.IO.File]::WriteAllLines($navbar, $newNavbar, $utf8NoBomEncoding)
Write-Output "Commit and push now, press any key to continue" Write-Output "Commit and push now, press any key to continue"
[Console]::ReadKey() [Console]::ReadKey()
cd client cd client
npm install npm install
ng build ng build --configuration production --output-path=..\server\RdtClient.Web\wwwroot
cd .. cd ..
cd server cd server

4611
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
package.json Normal file
View file

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

View file

@ -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,66 +1,41 @@
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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, String path)
{ {
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(),
TorrentId = torrentId, TorrentId = torrentId,
FileName = downloadInfo.FileName, Path = path,
Path = downloadInfo.RestrictedLink,
Added = DateTimeOffset.UtcNow, Added = DateTimeOffset.UtcNow,
DownloadQueued = DateTimeOffset.UtcNow, DownloadQueued = DateTimeOffset.UtcNow,
RetryCount = 0 RetryCount = 0
@ -68,42 +43,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,27 +63,14 @@ public class DownloadData(DataContext dataContext, ILogger<DownloadData>? logger
dbDownload.Link = unrestrictedLink; dbDownload.Link = unrestrictedLink;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
}
public async Task UpdateFileName(Guid downloadId, String fileName) await TorrentData.VoidCache();
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);
if (dbDownload == null)
{
return;
}
dbDownload.FileName = fileName;
await dataContext.SaveChangesAsync();
} }
public async Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime) 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 +80,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 +97,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 +114,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 +131,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 +148,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 +165,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 +182,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 +199,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 +221,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 +250,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

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

View file

@ -9,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)
{ {
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)
{ {
@ -63,11 +71,10 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
return dbTorrent; return dbTorrent;
} }
public async Task<Torrent> Add(String? rdId, public async Task<Torrent> Add(String rdId,
String hash, String hash,
String? fileOrMagnetContents, String? fileOrMagnetContents,
Boolean isFile, Boolean isFile,
DownloadType downloadType,
DownloadClient downloadClient, DownloadClient downloadClient,
Torrent torrent) Torrent torrent)
{ {
@ -79,7 +86,6 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
Hash = hash.ToLower(), Hash = hash.ToLower(),
Category = torrent.Category, Category = torrent.Category,
HostDownloadAction = torrent.HostDownloadAction, HostDownloadAction = torrent.HostDownloadAction,
FinishedActionDelay = torrent.FinishedActionDelay,
DownloadAction = torrent.DownloadAction, DownloadAction = torrent.DownloadAction,
FinishedAction = torrent.FinishedAction, FinishedAction = torrent.FinishedAction,
DownloadMinSize = torrent.DownloadMinSize, DownloadMinSize = torrent.DownloadMinSize,
@ -87,22 +93,21 @@ 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,
TorrentRetryAttempts = torrent.TorrentRetryAttempts, TorrentRetryAttempts = torrent.TorrentRetryAttempts,
DownloadRetryAttempts = torrent.DownloadRetryAttempts, DownloadRetryAttempts = torrent.DownloadRetryAttempts,
DeleteOnError = torrent.DeleteOnError, DeleteOnError = torrent.DeleteOnError,
Lifetime = torrent.Lifetime, Lifetime = torrent.Lifetime
RdStatus = torrent.RdStatus,
RdName = torrent.RdName
}; };
await dataContext.Torrents.AddAsync(newTorrent); await dataContext.Torrents.AddAsync(newTorrent);
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
await VoidCache();
return newTorrent; return newTorrent;
} }
@ -129,34 +134,8 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
dbTorrent.RdFiles = torrent.RdFiles; dbTorrent.RdFiles = torrent.RdFiles;
await dataContext.SaveChangesAsync(); await dataContext.SaveChangesAsync();
}
public async Task UpdateRdId(Torrent torrent, String rdId) await VoidCache();
{
var dbTorrent = await dataContext.Torrents.FirstOrDefaultAsync(m => m.TorrentId == torrent.TorrentId);
if (dbTorrent == null)
{
return;
}
dbTorrent.RdId = rdId;
await dataContext.SaveChangesAsync();
}
public async Task UpdateHash(Torrent torrent, String hash)
{
var dbTorrent = await dataContext.Torrents.FirstOrDefaultAsync(m => m.TorrentId == torrent.TorrentId);
if (dbTorrent == null)
{
return;
}
dbTorrent.Hash = hash.ToLower();
await dataContext.SaveChangesAsync();
} }
public async Task Update(Torrent torrent) public async Task Update(Torrent torrent)
@ -178,6 +157,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 +173,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 +210,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 +226,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 +242,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 +259,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 +275,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,12 +14,12 @@ 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>();
services.AddScoped<SettingData>(); services.AddScoped<SettingData>();
services.AddScoped<ITorrentData, TorrentData>(); services.AddScoped<TorrentData>();
services.AddScoped<UserData>(); services.AddScoped<UserData>();
} }
} }

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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,6 @@ 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 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 +29,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; }
@ -50,7 +44,6 @@ public class Torrent
[InverseProperty("Torrent")] [InverseProperty("Torrent")]
public IList<Download> Downloads { get; set; } = []; public IList<Download> Downloads { get; set; } = [];
public Provider? ClientKind { get; set; }
public String? RdId { get; set; } public String? RdId { get; set; }
public String? RdName { get; set; } public String? RdName { get; set; }
public Int64? RdSize { get; set; } public Int64? RdSize { get; set; }
@ -63,52 +56,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,5 +1,4 @@
using System.ComponentModel; using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global
@ -44,7 +43,7 @@ public class DbSettingsGeneral
public Int32 DownloadLimit { get; set; } = 2; public Int32 DownloadLimit { get; set; } = 2;
[DisplayName("Maximum unpack processes")] [DisplayName("Maximum unpack processes")]
[Description("Maximum amount of downloads that get unpacked on your host at the same time. Set to 0 to disable unpacking.")] [Description("Maximum amount of downloads that get unpacked on your host at the same time.")]
public Int32 UnpackLimit { get; set; } = 1; public Int32 UnpackLimit { get; set; } = 1;
[DisplayName("Categories")] [DisplayName("Categories")]
@ -75,22 +74,6 @@ Supports the following parameters:
[DisplayName("Copy added torrent files")] [DisplayName("Copy added torrent files")]
[Description("When a torrent file or magnet is added, create a copy in this directory.")] [Description("When a torrent file or magnet is added, create a copy in this directory.")]
public String? CopyAddedTorrents { get; set; } = null; public String? CopyAddedTorrents { get; set; } = null;
[DisplayName("Tracker enrichment list")]
[Description("Optional. Specify the URL of a tracker list file to be appended to magnet links and torrent files.")]
public String? TrackerEnrichmentList { get; set; } = null;
[DisplayName("Tracker enrichment cache expiration")]
[Description("The time in minutes to cache the tracker list. Set to 0 to disable caching.")]
public Int32 TrackerEnrichmentCacheExpiration { get; set; } = 60;
[DisplayName("Banned Trackers")]
[Description("Torrents that come from these trackers will not be allowed, this is a failsafe if you are accidentally downloading from private trackers. Will compare by keyword. Define multiple trackers by separating them with a comma.")]
public String? BannedTrackers { get; set; } = null;
[DisplayName("Disable update notifications")]
[Description("Ignore update notifications. You will still be notified if the version you are running has a security vulnerability.")]
public Boolean DisableUpdateNotifications { get; set; } = false;
} }
public class DbSettingsDownloadClient public class DbSettingsDownloadClient
@ -98,7 +81,7 @@ public class DbSettingsDownloadClient
[DisplayName("Download client")] [DisplayName("Download client")]
[Description(@"Select which download client to use, see the [Description(@"Select which download client to use, see the
<a href=""https://github.com/rogerfar/rdt-client/"" target=""_blank"">README</a> for the various options.")] <a href=""https://github.com/rogerfar/rdt-client/"" target=""_blank"">README</a> for the various options.")]
public DownloadClient Client { get; set; } = DownloadClient.Bezzad; public DownloadClient Client { get; set; } = DownloadClient.Internal;
[DisplayName("Download path")] [DisplayName("Download path")]
[Description("Path in the docker container to download files to (i.e. /data/downloads), or a local path when using as a service.")] [Description("Path in the docker container to download files to (i.e. /data/downloads), or a local path when using as a service.")]
@ -128,8 +111,8 @@ public class DbSettingsDownloadClient
[Description("Timeout in milliseconds before the downloader times out.")] [Description("Timeout in milliseconds before the downloader times out.")]
public Int32 Timeout { get; set; } = 5000; public Int32 Timeout { get; set; } = 5000;
[DisplayName("Proxy Server (only used for the Bezzad Downloader)")] [DisplayName("Proxy Server (only used for the Internal Downloader)")]
[Description("Address of a proxy server to download through (only used for the Bezzad Downloader).")] [Description("Address of a proxy server to download through (only used for the Internal Downloader).")]
public String? ProxyServer { get; set; } = null; public String? ProxyServer { get; set; } = null;
[DisplayName("Aria2c URL (only used for the Aria2c Downloader)")] [DisplayName("Aria2c URL (only used for the Aria2c Downloader)")]
@ -149,30 +132,6 @@ http://127.0.0.1:6800/jsonrpc.")]
[Description("Path where Rclone is mounted. Required for Symlink Downloader. Suffix this path with a * to search subdirectories too.")] [Description("Path where Rclone is mounted. Required for Symlink Downloader. Suffix this path with a * to search subdirectories too.")]
public String RcloneMountPath { get; set; } = "/mnt/rd/"; public String RcloneMountPath { get; set; } = "/mnt/rd/";
[DisplayName("Synology DownloadStation URL")]
[Description("The URL to the Synology DownloadStation. A common URL is http://127.0.0.1:5000")]
public String DownloadStationUrl { get; set; } = "http://127.0.0.1:5000";
[DisplayName("Synology DownloadStation Username")]
[Description("The username to use when connecting to the Synology DownloadStation.")]
public String? DownloadStationUsername { get; set; } = null;
[DisplayName("Synology DownloadStation Password")]
[Description("The password to use when connecting to the Synology DownloadStation.")]
public String? DownloadStationPassword { get; set; } = null;
[DisplayName("Synology Download Station Download 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;
[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;
@ -185,7 +144,6 @@ public class DbSettingsProvider
<a href=""https://real-debrid.com/?id=1348683"" target=""_blank"" rel=""noopener"">https://real-debrid.com</a> <a href=""https://real-debrid.com/?id=1348683"" target=""_blank"" rel=""noopener"">https://real-debrid.com</a>
<a href=""https://alldebrid.com/?uid=2v91l&lang=en"" target=""_blank"" rel=""noopener"">https://alldebrid.com</a> <a href=""https://alldebrid.com/?uid=2v91l&lang=en"" target=""_blank"" rel=""noopener"">https://alldebrid.com</a>
<a href=""https://www.premiumize.me/"" target=""_blank"" rel=""noopener"">https://www.premiumize.me/</a> <a href=""https://www.premiumize.me/"" target=""_blank"" rel=""noopener"">https://www.premiumize.me/</a>
<a href=""https://debrid-link.com/"" target=""_blank"" rel=""noopener"">https://debrid-link.com/</a>
<a href=""https://torbox.app/"" target=""_blank"" rel=""noopener"">https://torbox.app/</a> <a href=""https://torbox.app/"" target=""_blank"" rel=""noopener"">https://torbox.app/</a>
At this point only 1 provider can be used at the time.")] At this point only 1 provider can be used at the time.")]
public Provider Provider { get; set; } = Provider.RealDebrid; public Provider Provider { get; set; } = Provider.RealDebrid;
@ -194,22 +152,13 @@ At this point only 1 provider can be used at the time.")]
[Description(@"You can find your API key here: [Description(@"You can find your API key here:
<a href=""https://real-debrid.com/apitoken"" target=""_blank"" rel=""noopener"">https://real-debrid.com/apitoken</a> <a href=""https://real-debrid.com/apitoken"" target=""_blank"" rel=""noopener"">https://real-debrid.com/apitoken</a>
or or
<a href=""https://alldebrid.com/apikeys/"" target=""_blank"" rel=""noopener"">https://alldebrid.com/apikeys/</a> <a href=""""https://alldebrid.com/apikeys/"""" target=""""_blank"""" rel=""""noopener"""">https://alldebrid.com/apikeys/</a>
or or
<a href=""https://www.premiumize.me/account/"" target=""_blank"" rel=""noopener"">https://www.premiumize.me/account/</a> <a href=""https://www.premiumize.me/account/"" target=""_blank"" rel=""noopener"">https://www.premiumize.me/account/</a>
or or
<a href=""https://torbox.app/settings/"" target=""_blank"" rel=""noopener"">https://torbox.app/settings/</a> <a href=""""https://torbox.app/settings/"""" target=""""_blank"""" rel=""""noopener"""">https://torbox.app/settings/</a>")]
or
<a href=""https://debrid-link.com/webapp/apikey"" target=""_blank"" rel=""noopener"">https://debrid-link.com/webapp/apikey</a>")]
public String ApiKey { get; set; } = ""; public String ApiKey { get; set; } = "";
/// <summary>
/// API hostname to use <b>for Real Debrid only</b>
/// </summary>
[DisplayName("API Hostname (RD only)")]
[Description("Use this instead of the normal hostname for Real Debrid API requests. Only used by Real Debrid. Leave blank to use default.")]
public String? ApiHostname { get; set; }
[DisplayName("Automatically import and process torrents added to provider")] [DisplayName("Automatically import and process torrents added to provider")]
[Description("When selected, import downloads that are not added through RealDebridClient but have been directly added to your debrid provider.")] [Description("When selected, import downloads that are not added through RealDebridClient but have been directly added to your debrid provider.")]
public Boolean AutoImport { get; set; } = false; public Boolean AutoImport { get; set; } = false;
@ -226,14 +175,6 @@ or
[Description("The interval to check the torrents info on the providers API. Minumum is 3 seconds. When there are no active downloads this limit is increased * 3.")] [Description("The interval to check the torrents info on the providers API. Minumum is 3 seconds. When there are no active downloads this limit is increased * 3.")]
public Int32 CheckInterval { get; set; } = 10; public Int32 CheckInterval { get; set; } = 10;
[DisplayName("Max parallel downloads")]
[Description("Limits the number of torrents that will be sent for downloading on the debrid provider at the same time. If set to 0, all downloads will be sent immediately without queuing.")]
public Int32 MaxParallelDownloads { get; set; } = 0;
[DisplayName("Prefer zipped downloads")]
[Description("Torbox only. When selected, rdt-client will try to download the entire torrent as a .zip from TorBox and unpack it instead of downloading each file individually.")]
public Boolean PreferZippedDownloads { get; set; } = false;
[DisplayName("Auto Import Defaults")] [DisplayName("Auto Import Defaults")]
public DbSettingsDefaultsWithCategory Default { get; set; } = new(); public DbSettingsDefaultsWithCategory Default { get; set; } = new();
} }
@ -264,7 +205,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")]
@ -284,10 +224,6 @@ public class DbSettingsDefaultsWithCategory : DbSettingsDefaults
[DisplayName("Post Download Action")] [DisplayName("Post Download Action")]
[Description("When all files are downloaded from the provider to the host, perform this action. Does not apply when using the symlink downloader.")] [Description("When all files are downloaded from the provider to the host, perform this action. Does not apply when using the symlink downloader.")]
public TorrentFinishedAction FinishedAction { get; set; } = TorrentFinishedAction.RemoveAllTorrents; public TorrentFinishedAction FinishedAction { get; set; } = TorrentFinishedAction.RemoveAllTorrents;
[DisplayName("Finished Action Delay")]
[Description("When all files are downloaded from the provider to the host, wait this many minutes before performing the action above.")]
public Int32 FinishedActionDelay { get; set; } = 0;
} }
public class DbSettingsDefaults public class DbSettingsDefaults

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

@ -7,6 +7,4 @@ public class Profile
public DateTimeOffset? Expiration { get; set; } public DateTimeOffset? Expiration { get; set; }
public String? CurrentVersion { get; set; } public String? CurrentVersion { get; set; }
public String? LatestVersion { get; set; } public String? LatestVersion { get; set; }
public Boolean? IsInsecure { get; set; }
public Boolean? DisableUpdateNotification { get; set; }
} }

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