From e93e4f6f22e3e33407c19c48100984c1c679f821 Mon Sep 17 00:00:00 2001 From: omgbeez <251408589+omgbeez@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:15:57 -0500 Subject: [PATCH] Usenet support --- .../add-new-torrent.component.html | 105 +++- .../add-new-torrent.component.ts | 84 ++- client/src/app/models/torrent.model.ts | 6 + client/src/app/navbar/navbar.component.html | 6 +- client/src/app/torrent-status.pipe.ts | 2 +- client/src/app/torrent.service.ts | 14 + server/RdtClient.Data/Data/ITorrentData.cs | 1 + server/RdtClient.Data/Data/SettingData.cs | 2 +- server/RdtClient.Data/Data/TorrentData.cs | 16 + server/RdtClient.Data/Enums/DownloadType.cs | 7 + ...22148_AddDownloadTypeToTorrent.Designer.cs | 485 ++++++++++++++++++ ...20251224022148_AddDownloadTypeToTorrent.cs | 29 ++ .../Migrations/DataContextModelSnapshot.cs | 3 + server/RdtClient.Data/Models/Data/Torrent.cs | 7 +- .../DebridClientAvailableFile.cs} | 4 +- .../DebridClientFile.cs} | 4 +- .../DebridClientTorrent.cs} | 10 +- .../DebridClientUser.cs} | 4 +- .../Models/Internal/DbSettings.cs | 6 +- .../Models/Internal/TorrentDto.cs | 5 +- .../Models/Sabnzbd/SabnzbdCategory.cs | 24 + .../Models/Sabnzbd/SabnzbdConfig.cs | 15 + .../Models/Sabnzbd/SabnzbdHistory.cs | 15 + .../Models/Sabnzbd/SabnzbdHistorySlot.cs | 24 + .../Models/Sabnzbd/SabnzbdMisc.cs | 18 + .../Models/Sabnzbd/SabnzbdQueue.cs | 27 + .../Models/Sabnzbd/SabnzbdQueueSlot.cs | 36 ++ .../Models/Sabnzbd/SabnzbdResponse.cs | 30 ++ .../WatchFolderCheckerTests.cs | 213 ++++++++ .../Helpers/DownloadHelperTest.cs | 6 +- .../Helpers/FileHelperTest.cs | 101 ++++ .../RdtClient.Service.Test.csproj | 7 + .../Services/NzbTorrentsTest.cs | 135 +++++ .../Services/QBittorrentTest.cs | 57 ++ .../Services/SabnzbdTest.cs | 399 ++++++++++++++ ...ntTest.cs => AllDebridDebridClientTest.cs} | 60 +-- .../TorrentClients/TorBoxDebridClientTest.cs | 481 +++++++++++++++++ .../Services/TorrentsTest.cs | 92 ++++ .../BackgroundServices/WatchFolderChecker.cs | 23 +- server/RdtClient.Service/DiConfig.cs | 12 +- .../Helpers/CredentialRedactorEnricher.cs | 66 +++ .../RdtClient.Service/Helpers/FileHelper.cs | 10 +- .../Helpers/FileSizeHelper.cs | 24 + .../Middleware/SabnzbdHandler.cs | 58 +++ .../RdtClient.Service.csproj | 2 +- .../Services/Authentication.cs | 2 +- .../AllDebridDebridClient.cs} | 99 ++-- .../DebridLinkTorrentClient.cs | 182 +++---- .../IDebridClient.cs} | 26 +- .../PremiumizeDebridClient.cs} | 158 +++--- .../RealDebridDebridClient.cs} | 196 +++---- .../TorBoxDebridClient.cs} | 425 ++++++++++----- .../Services/DownloadClient.cs | 4 +- .../RdtClient.Service/Services/QBittorrent.cs | 21 +- .../Services/RemoteService.cs | 120 +++-- server/RdtClient.Service/Services/Sabnzbd.cs | 220 ++++++++ server/RdtClient.Service/Services/Torrents.cs | 298 ++++++++--- .../Services/UnpackClient.cs | 4 +- .../Controllers/SabnzbdControllerTest.cs | 255 +++++++++ .../Controllers/SabnzbdHandlerTest.cs | 118 +++++ .../Controllers/TorrentsControllerNzbTest.cs | 111 ++++ .../RdtClient.Web.Test.csproj | 32 ++ .../RdtClient.Web/.config/dotnet-tools.json | 5 +- .../Controllers/QBittorrentController.cs | 1 + .../Controllers/SabnzbdController.cs | 151 ++++++ .../Controllers/SabnzbdModeAttribute.cs | 23 + .../Controllers/TorrentsController.cs | 236 ++++++--- server/RdtClient.Web/Program.cs | 9 +- server/RdtClient.sln | 50 ++ 69 files changed, 4678 insertions(+), 803 deletions(-) create mode 100644 server/RdtClient.Data/Enums/DownloadType.cs create mode 100644 server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.Designer.cs create mode 100644 server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.cs rename server/RdtClient.Data/Models/{TorrentClient/TorrentClientAvailableFile.cs => DebridClient/DebridClientAvailableFile.cs} (52%) rename server/RdtClient.Data/Models/{TorrentClient/TorrentClientFile.cs => DebridClient/DebridClientFile.cs} (72%) rename server/RdtClient.Data/Models/{TorrentClient/TorrentClientTorrent.cs => DebridClient/DebridClientTorrent.cs} (76%) rename server/RdtClient.Data/Models/{TorrentClient/TorrentClientUser.cs => DebridClient/DebridClientUser.cs} (55%) create mode 100644 server/RdtClient.Data/Models/Sabnzbd/SabnzbdCategory.cs create mode 100644 server/RdtClient.Data/Models/Sabnzbd/SabnzbdConfig.cs create mode 100644 server/RdtClient.Data/Models/Sabnzbd/SabnzbdHistory.cs create mode 100644 server/RdtClient.Data/Models/Sabnzbd/SabnzbdHistorySlot.cs create mode 100644 server/RdtClient.Data/Models/Sabnzbd/SabnzbdMisc.cs create mode 100644 server/RdtClient.Data/Models/Sabnzbd/SabnzbdQueue.cs create mode 100644 server/RdtClient.Data/Models/Sabnzbd/SabnzbdQueueSlot.cs create mode 100644 server/RdtClient.Data/Models/Sabnzbd/SabnzbdResponse.cs create mode 100644 server/RdtClient.Service.Test/BackgroundServices/WatchFolderCheckerTests.cs create mode 100644 server/RdtClient.Service.Test/Helpers/FileHelperTest.cs create mode 100644 server/RdtClient.Service.Test/Services/NzbTorrentsTest.cs create mode 100644 server/RdtClient.Service.Test/Services/QBittorrentTest.cs create mode 100644 server/RdtClient.Service.Test/Services/SabnzbdTest.cs rename server/RdtClient.Service.Test/Services/TorrentClients/{AllDebridTorrentClientTest.cs => AllDebridDebridClientTest.cs} (82%) create mode 100644 server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs create mode 100644 server/RdtClient.Service/Helpers/CredentialRedactorEnricher.cs create mode 100644 server/RdtClient.Service/Helpers/FileSizeHelper.cs create mode 100644 server/RdtClient.Service/Middleware/SabnzbdHandler.cs rename server/RdtClient.Service/Services/{TorrentClients/AllDebridTorrentClient.cs => DebridClients/AllDebridDebridClient.cs} (86%) rename server/RdtClient.Service/Services/{TorrentClients => DebridClients}/DebridLinkTorrentClient.cs (86%) rename server/RdtClient.Service/Services/{TorrentClients/ITorrentClient.cs => DebridClients/IDebridClient.cs} (56%) rename server/RdtClient.Service/Services/{TorrentClients/PremiumizeTorrentClient.cs => DebridClients/PremiumizeDebridClient.cs} (87%) rename server/RdtClient.Service/Services/{TorrentClients/RealDebridTorrentClient.cs => DebridClients/RealDebridDebridClient.cs} (87%) rename server/RdtClient.Service/Services/{TorrentClients/TorBoxTorrentClient.cs => DebridClients/TorBoxDebridClient.cs} (54%) create mode 100644 server/RdtClient.Service/Services/Sabnzbd.cs create mode 100644 server/RdtClient.Web.Test/Controllers/SabnzbdControllerTest.cs create mode 100644 server/RdtClient.Web.Test/Controllers/SabnzbdHandlerTest.cs create mode 100644 server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs create mode 100644 server/RdtClient.Web.Test/RdtClient.Web.Test.csproj create mode 100644 server/RdtClient.Web/Controllers/SabnzbdController.cs create mode 100644 server/RdtClient.Web/Controllers/SabnzbdModeAttribute.cs diff --git a/client/src/app/add-new-torrent/add-new-torrent.component.html b/client/src/app/add-new-torrent/add-new-torrent.component.html index 0a3eee6..56b1c68 100644 --- a/client/src/app/add-new-torrent/add-new-torrent.component.html +++ b/client/src/app/add-new-torrent/add-new-torrent.component.html @@ -1,5 +1,24 @@
-
+ +
+ +
+
@@ -31,15 +50,53 @@ >
+
+
-
- + +
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+ +
Advanced settings
@@ -53,7 +110,7 @@

- Select which downloader is used to download this torrent from the debrid provider to your host. + Select which downloader is used to download this from the debrid provider to your host.

@@ -66,7 +123,7 @@

- When a torrent is finished downloading on the provider, perform this action. Use this setting if you only want + 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.

@@ -142,10 +199,10 @@
@@ -156,7 +213,7 @@

- When a torrent is finished downloading on the provider, perform this action. Use this setting if you only want + 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.

@@ -175,9 +232,14 @@

- Set the priority for this torrent where 1 is the highest. When empty it will be assigned the lowest priority. + Set the priority where 1 is the highest. When empty it will be assigned the lowest priority.

+ + + +
+
@@ -186,13 +248,13 @@

When a single download fails it will retry it this many times before marking it as failed.

- +

- When a single download has failed multiple times (see setting above) or when the torrent itself received an - error it will retry the full torrent this many times before marking it failed. + When a single download has failed multiple times (see setting above) or when the download itself received an + error it will retry the full download this many times before marking it failed.

@@ -206,13 +268,13 @@

- +

- The maximum lifetime of a torrent in minutes. When this time has passed, mark the torrent as error. If the - torrent is completed and has downloads, the lifetime setting will not apply. 0 to disable. + The maximum lifetime of a download in minutes. When this time has passed, mark it as error. If the + download is completed, the lifetime setting will not apply. 0 to disable.

@@ -226,10 +288,3 @@
-
-
- -
-
diff --git a/client/src/app/add-new-torrent/add-new-torrent.component.ts b/client/src/app/add-new-torrent/add-new-torrent.component.ts index f3491a0..a96198c 100644 --- a/client/src/app/add-new-torrent/add-new-torrent.component.ts +++ b/client/src/app/add-new-torrent/add-new-torrent.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { TorrentService } from 'src/app/torrent.service'; -import { Torrent, TorrentFileAvailability } from '../models/torrent.model'; +import { DownloadType, Torrent, TorrentFileAvailability } from '../models/torrent.model'; import { SettingsService } from '../settings.service'; import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -15,8 +15,10 @@ import { NgClass } from '@angular/common'; standalone: true, }) export class AddNewTorrentComponent implements OnInit { + public type: 'torrent' | 'nzb' = 'torrent'; public fileName: string; public magnetLink: string; + public nzbLink: string; private currentTorrentFile: string; public provider: string; @@ -58,8 +60,19 @@ export class AddNewTorrentComponent implements OnInit { ngOnInit(): void { this.activatedRoute.queryParams.subscribe((params) => { + if (params['type'] === 'nzb') { + this.type = 'nzb'; + } else if (params['type'] === 'torrent') { + this.type = 'torrent'; + } + if (params['magnet']) { this.magnetLink = decodeURIComponent(params['magnet']); + this.type = 'torrent'; + } + if (params['nzb']) { + this.nzbLink = decodeURIComponent(params['nzb']); + this.type = 'nzb'; } }); this.settingsService.get().subscribe((settings) => { @@ -97,6 +110,12 @@ export class AddNewTorrentComponent implements OnInit { } } + public changeType(type: 'torrent' | 'nzb'): void { + this.type = type; + this.fileName = null; + this.selectedFile = null; + } + public pickFile(evt: Event): void { const files = (evt.target as HTMLInputElement).files; @@ -152,25 +171,49 @@ export class AddNewTorrentComponent implements OnInit { torrent.lifetime = this.torrentLifetime; torrent.downloadClient = this.downloadClient; - if (this.magnetLink) { - this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe({ - next: () => this.router.navigate(['/']), - error: (err) => { - this.error = err.error; - this.saving = false; - }, - }); - } else if (this.selectedFile) { - this.torrentService.uploadFile(this.selectedFile, torrent).subscribe({ - next: () => this.router.navigate(['/']), - error: (err) => { - this.error = err.error; - this.saving = false; - }, - }); + if (this.type === 'torrent') { + torrent.type = DownloadType.Torrent; + if (this.magnetLink) { + this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe({ + next: () => this.router.navigate(['/']), + error: (err) => { + this.error = err.error; + this.saving = false; + }, + }); + } else if (this.selectedFile) { + this.torrentService.uploadFile(this.selectedFile, torrent).subscribe({ + next: () => this.router.navigate(['/']), + error: (err) => { + this.error = err.error; + this.saving = false; + }, + }); + } else { + this.error = 'No magnet or file uploaded'; + this.saving = false; + } } else { - this.error = 'No magnet or file uploaded'; - this.saving = false; + if (this.nzbLink) { + this.torrentService.uploadNzbLink(this.nzbLink, torrent).subscribe({ + next: () => this.router.navigate(['/']), + error: (err) => { + this.error = err.error; + this.saving = false; + }, + }); + } else if (this.selectedFile) { + this.torrentService.uploadNzbFile(this.selectedFile, torrent).subscribe({ + next: () => this.router.navigate(['/']), + error: (err) => { + this.error = err.error; + this.saving = false; + }, + }); + } else { + this.error = 'No NZB link or file uploaded'; + this.saving = false; + } } } @@ -181,6 +224,9 @@ export class AddNewTorrentComponent implements OnInit { } public checkFiles(): void { + if (this.type === 'nzb') { + return; + } if (this.magnetLink && this.magnetLink === this.currentTorrentFile) { return; } diff --git a/client/src/app/models/torrent.model.ts b/client/src/app/models/torrent.model.ts index 7ef1646..29cd9c5 100644 --- a/client/src/app/models/torrent.model.ts +++ b/client/src/app/models/torrent.model.ts @@ -28,6 +28,7 @@ export class Torrent { public lifetime: number; public priority: number; + public type: DownloadType; public error: string; public rdId: string; @@ -73,3 +74,8 @@ export enum RealDebridStatus { Error = 99, } + +export enum DownloadType { + Torrent = 0, + Nzb = 1, +} diff --git a/client/src/app/navbar/navbar.component.html b/client/src/app/navbar/navbar.component.html index 1b9755f..ba58124 100644 --- a/client/src/app/navbar/navbar.component.html +++ b/client/src/app/navbar/navbar.component.html @@ -24,13 +24,13 @@ - Torrents + Torrents/NZBs - + - Add New Torrent + Add Torrent/NZB diff --git a/client/src/app/torrent-status.pipe.ts b/client/src/app/torrent-status.pipe.ts index 6844f3e..6c86820 100644 --- a/client/src/app/torrent-status.pipe.ts +++ b/client/src/app/torrent-status.pipe.ts @@ -77,7 +77,7 @@ export class TorrentStatusPipe implements PipeTransform { case RealDebridStatus.Queued: return 'Not Yet Added to Provider'; case RealDebridStatus.Downloading: - if (torrent.rdSeeders < 1) { + if (torrent.rdSeeders < 1 && torrent.type !== 1) { return `Torrent stalled`; } const speed = this.pipe.transform(torrent.rdSpeed, 'filesize'); diff --git a/client/src/app/torrent.service.ts b/client/src/app/torrent.service.ts index 8ecd2f0..061d724 100644 --- a/client/src/app/torrent.service.ts +++ b/client/src/app/torrent.service.ts @@ -79,6 +79,20 @@ export class TorrentService { return this.http.post(`${this.baseHref}Api/Torrents/UploadFile`, formData); } + public uploadNzbLink(nzbLink: string, torrent: Torrent): Observable { + return this.http.post(`${this.baseHref}Api/Torrents/UploadNzbLink`, { + nzbLink, + torrent, + }); + } + + public uploadNzbFile(file: File, torrent: Torrent): Observable { + const formData: FormData = new FormData(); + formData.append('file', file); + formData.append('formData', JSON.stringify({ torrent })); + return this.http.post(`${this.baseHref}Api/Torrents/UploadNzbFile`, formData); + } + public checkFilesMagnet(magnetLink: string): Observable { return this.http.post(`${this.baseHref}Api/Torrents/CheckFilesMagnet`, { magnetLink, diff --git a/server/RdtClient.Data/Data/ITorrentData.cs b/server/RdtClient.Data/Data/ITorrentData.cs index 9c286e3..7b04b88 100644 --- a/server/RdtClient.Data/Data/ITorrentData.cs +++ b/server/RdtClient.Data/Data/ITorrentData.cs @@ -13,6 +13,7 @@ public interface ITorrentData String hash, String? fileOrMagnetContents, Boolean isFile, + DownloadType downloadType, DownloadClient downloadClient, Torrent torrent); diff --git a/server/RdtClient.Data/Data/SettingData.cs b/server/RdtClient.Data/Data/SettingData.cs index 51c0d59..311697d 100644 --- a/server/RdtClient.Data/Data/SettingData.cs +++ b/server/RdtClient.Data/Data/SettingData.cs @@ -9,7 +9,7 @@ namespace RdtClient.Data.Data; public class SettingData(DataContext dataContext, ILogger logger) { - public static DbSettings Get { get; } = new(); + public static DbSettings Get { get; private set; } = new(); public static IList GetAll() { diff --git a/server/RdtClient.Data/Data/TorrentData.cs b/server/RdtClient.Data/Data/TorrentData.cs index 3a0c108..e790b1a 100644 --- a/server/RdtClient.Data/Data/TorrentData.cs +++ b/server/RdtClient.Data/Data/TorrentData.cs @@ -65,6 +65,7 @@ public class TorrentData(DataContext dataContext) : ITorrentData String hash, String? fileOrMagnetContents, Boolean isFile, + DownloadType downloadType, DownloadClient downloadClient, Torrent torrent) { @@ -84,6 +85,7 @@ public class TorrentData(DataContext dataContext) : ITorrentData ExcludeRegex = torrent.ExcludeRegex, DownloadManualFiles = torrent.DownloadManualFiles, DownloadClient = downloadClient, + Type = downloadType, FileOrMagnet = fileOrMagnetContents, IsFile = isFile, Priority = torrent.Priority, @@ -141,6 +143,20 @@ public class TorrentData(DataContext dataContext) : ITorrentData 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) { var dbTorrent = await dataContext.Torrents.FirstOrDefaultAsync(m => m.TorrentId == torrent.TorrentId); diff --git a/server/RdtClient.Data/Enums/DownloadType.cs b/server/RdtClient.Data/Enums/DownloadType.cs new file mode 100644 index 0000000..644a892 --- /dev/null +++ b/server/RdtClient.Data/Enums/DownloadType.cs @@ -0,0 +1,7 @@ +namespace RdtClient.Data.Enums; + +public enum DownloadType +{ + Torrent = 0, + Nzb = 1 +} diff --git a/server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.Designer.cs b/server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.Designer.cs new file mode 100644 index 0000000..2c1822a --- /dev/null +++ b/server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.Designer.cs @@ -0,0 +1,485 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b => + { + b.Property("DownloadId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Added") + .HasColumnType("TEXT"); + + b.Property("Completed") + .HasColumnType("TEXT"); + + b.Property("DownloadFinished") + .HasColumnType("TEXT"); + + b.Property("DownloadQueued") + .HasColumnType("TEXT"); + + b.Property("DownloadStarted") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Link") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("TorrentId") + .HasColumnType("TEXT"); + + b.Property("UnpackingFinished") + .HasColumnType("TEXT"); + + b.Property("UnpackingQueued") + .HasColumnType("TEXT"); + + b.Property("UnpackingStarted") + .HasColumnType("TEXT"); + + b.HasKey("DownloadId"); + + b.HasIndex("TorrentId"); + + b.ToTable("Downloads"); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.Setting", b => + { + b.Property("SettingId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("SettingId"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b => + { + b.Property("TorrentId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Added") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ClientKind") + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("TEXT"); + + b.Property("DeleteOnError") + .HasColumnType("INTEGER"); + + b.Property("DownloadAction") + .HasColumnType("INTEGER"); + + b.Property("DownloadClient") + .HasColumnType("INTEGER"); + + b.Property("DownloadManualFiles") + .HasColumnType("TEXT"); + + b.Property("DownloadMinSize") + .HasColumnType("INTEGER"); + + b.Property("DownloadRetryAttempts") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("ExcludeRegex") + .HasColumnType("TEXT"); + + b.Property("FileOrMagnet") + .HasColumnType("TEXT"); + + b.Property("FilesSelected") + .HasColumnType("TEXT"); + + b.Property("FinishedAction") + .HasColumnType("INTEGER"); + + b.Property("FinishedActionDelay") + .HasColumnType("INTEGER"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HostDownloadAction") + .HasColumnType("INTEGER"); + + b.Property("IncludeRegex") + .HasColumnType("TEXT"); + + b.Property("IsFile") + .HasColumnType("INTEGER"); + + b.Property("Lifetime") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RdAdded") + .HasColumnType("TEXT"); + + b.Property("RdEnded") + .HasColumnType("TEXT"); + + b.Property("RdFiles") + .HasColumnType("TEXT"); + + b.Property("RdHost") + .HasColumnType("TEXT"); + + b.Property("RdId") + .HasColumnType("TEXT"); + + b.Property("RdName") + .HasColumnType("TEXT"); + + b.Property("RdProgress") + .HasColumnType("INTEGER"); + + b.Property("RdSeeders") + .HasColumnType("INTEGER"); + + b.Property("RdSize") + .HasColumnType("INTEGER"); + + b.Property("RdSpeed") + .HasColumnType("INTEGER"); + + b.Property("RdSplit") + .HasColumnType("INTEGER"); + + b.Property("RdStatus") + .HasColumnType("INTEGER"); + + b.Property("RdStatusRaw") + .HasColumnType("TEXT"); + + b.Property("Retry") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("TorrentRetryAttempts") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("TorrentId"); + + b.ToTable("Torrents"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.cs b/server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.cs new file mode 100644 index 0000000..c93f7b6 --- /dev/null +++ b/server/RdtClient.Data/Migrations/20251224022148_AddDownloadTypeToTorrent.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RdtClient.Data.Migrations +{ + /// + public partial class AddDownloadTypeToTorrent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "Torrents", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Type", + table: "Torrents"); + } + } +} diff --git a/server/RdtClient.Data/Migrations/DataContextModelSnapshot.cs b/server/RdtClient.Data/Migrations/DataContextModelSnapshot.cs index ba14f0f..b245454 100644 --- a/server/RdtClient.Data/Migrations/DataContextModelSnapshot.cs +++ b/server/RdtClient.Data/Migrations/DataContextModelSnapshot.cs @@ -402,6 +402,9 @@ namespace RdtClient.Data.Migrations b.Property("TorrentRetryAttempts") .HasColumnType("INTEGER"); + b.Property("Type") + .HasColumnType("INTEGER"); + b.HasKey("TorrentId"); b.ToTable("Torrents"); diff --git a/server/RdtClient.Data/Models/Data/Torrent.cs b/server/RdtClient.Data/Models/Data/Torrent.cs index e20b4aa..575f80b 100644 --- a/server/RdtClient.Data/Models/Data/Torrent.cs +++ b/server/RdtClient.Data/Models/Data/Torrent.cs @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; using RdtClient.Data.Enums; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; namespace RdtClient.Data.Models.Data; @@ -30,6 +30,7 @@ public class Torrent public DateTimeOffset? Completed { get; set; } public DateTimeOffset? Retry { get; set; } + public DownloadType Type { get; set; } public String? FileOrMagnet { get; set; } public Boolean IsFile { get; set; } @@ -61,7 +62,7 @@ public class Torrent public String? RdFiles { get; set; } [NotMapped] - public IList Files + public IList Files { get { @@ -72,7 +73,7 @@ public class Torrent try { - return JsonSerializer.Deserialize>(RdFiles) ?? []; + return JsonSerializer.Deserialize>(RdFiles) ?? []; } catch { diff --git a/server/RdtClient.Data/Models/TorrentClient/TorrentClientAvailableFile.cs b/server/RdtClient.Data/Models/DebridClient/DebridClientAvailableFile.cs similarity index 52% rename from server/RdtClient.Data/Models/TorrentClient/TorrentClientAvailableFile.cs rename to server/RdtClient.Data/Models/DebridClient/DebridClientAvailableFile.cs index 1ac01a3..61b5509 100644 --- a/server/RdtClient.Data/Models/TorrentClient/TorrentClientAvailableFile.cs +++ b/server/RdtClient.Data/Models/DebridClient/DebridClientAvailableFile.cs @@ -1,6 +1,6 @@ -namespace RdtClient.Data.Models.TorrentClient; +namespace RdtClient.Data.Models.DebridClient; -public class TorrentClientAvailableFile +public class DebridClientAvailableFile { public String Filename { get; set; } = default!; diff --git a/server/RdtClient.Data/Models/TorrentClient/TorrentClientFile.cs b/server/RdtClient.Data/Models/DebridClient/DebridClientFile.cs similarity index 72% rename from server/RdtClient.Data/Models/TorrentClient/TorrentClientFile.cs rename to server/RdtClient.Data/Models/DebridClient/DebridClientFile.cs index 351ed80..bfcfb17 100644 --- a/server/RdtClient.Data/Models/TorrentClient/TorrentClientFile.cs +++ b/server/RdtClient.Data/Models/DebridClient/DebridClientFile.cs @@ -1,6 +1,6 @@ -namespace RdtClient.Data.Models.TorrentClient; +namespace RdtClient.Data.Models.DebridClient; -public class TorrentClientFile +public class DebridClientFile { public Int64 Id { get; set; } public String Path { get; set; } = default!; diff --git a/server/RdtClient.Data/Models/TorrentClient/TorrentClientTorrent.cs b/server/RdtClient.Data/Models/DebridClient/DebridClientTorrent.cs similarity index 76% rename from server/RdtClient.Data/Models/TorrentClient/TorrentClientTorrent.cs rename to server/RdtClient.Data/Models/DebridClient/DebridClientTorrent.cs index 53e90cc..d16e2da 100644 --- a/server/RdtClient.Data/Models/TorrentClient/TorrentClientTorrent.cs +++ b/server/RdtClient.Data/Models/DebridClient/DebridClientTorrent.cs @@ -1,6 +1,8 @@ -namespace RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Enums; -public class TorrentClientTorrent +namespace RdtClient.Data.Models.DebridClient; + +public class DebridClientTorrent { public String Id { get; set; } = default!; public String Filename { get; set; } = default!; @@ -15,7 +17,9 @@ public class TorrentClientTorrent public String? Message { get; set; } public Int64 StatusCode { get; set; } public DateTimeOffset? Added { get; set; } - public List? Files { get; set; } + + public DownloadType Type { get; set; } = DownloadType.Torrent; + public List? Files { get; set; } public List? Links { get; set; } public DateTimeOffset? Ended { get; set; } public Int64? Speed { get; set; } diff --git a/server/RdtClient.Data/Models/TorrentClient/TorrentClientUser.cs b/server/RdtClient.Data/Models/DebridClient/DebridClientUser.cs similarity index 55% rename from server/RdtClient.Data/Models/TorrentClient/TorrentClientUser.cs rename to server/RdtClient.Data/Models/DebridClient/DebridClientUser.cs index abe2963..93d0384 100644 --- a/server/RdtClient.Data/Models/TorrentClient/TorrentClientUser.cs +++ b/server/RdtClient.Data/Models/DebridClient/DebridClientUser.cs @@ -1,6 +1,6 @@ -namespace RdtClient.Data.Models.TorrentClient; +namespace RdtClient.Data.Models.DebridClient; -public class TorrentClientUser +public class DebridClientUser { public String? Username { get; set; } public DateTimeOffset? Expiration { get; set; } diff --git a/server/RdtClient.Data/Models/Internal/DbSettings.cs b/server/RdtClient.Data/Models/Internal/DbSettings.cs index 0080bc2..4b88148 100644 --- a/server/RdtClient.Data/Models/Internal/DbSettings.cs +++ b/server/RdtClient.Data/Models/Internal/DbSettings.cs @@ -1,5 +1,6 @@ -using System.ComponentModel; -using RdtClient.Data.Enums; +using RdtClient.Data.Enums; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global @@ -263,6 +264,7 @@ public class DbSettingsWatch [DisplayName("Check Interval")] [Description("Time in seconds to check the folder for new files.")] + [Range(10, Int32.MaxValue, ErrorMessage = "Interval must be at least 10 seconds.")] public Int32 Interval { get; set; } = 60; [DisplayName("Import Defaults")] diff --git a/server/RdtClient.Data/Models/Internal/TorrentDto.cs b/server/RdtClient.Data/Models/Internal/TorrentDto.cs index ba51494..3694199 100644 --- a/server/RdtClient.Data/Models/Internal/TorrentDto.cs +++ b/server/RdtClient.Data/Models/Internal/TorrentDto.cs @@ -1,5 +1,5 @@ using RdtClient.Data.Enums; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; namespace RdtClient.Data.Models.Internal; @@ -20,6 +20,7 @@ public class TorrentDto public DateTimeOffset Added { get; set; } public DateTimeOffset? FilesSelected { get; set; } public DateTimeOffset? Completed { get; set; } + public DownloadType Type { get; set; } public Boolean IsFile { get; set; } public Int32? Priority { get; set; } public Int32 RetryCount { get; set; } @@ -40,7 +41,7 @@ public class TorrentDto public DateTimeOffset? RdEnded { get; set; } public Int64? RdSpeed { get; set; } public Int64? RdSeeders { get; set; } - public IList Files { get; set; } = []; + public IList Files { get; set; } = []; public IList Downloads { get; set; } = []; } diff --git a/server/RdtClient.Data/Models/Sabnzbd/SabnzbdCategory.cs b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdCategory.cs new file mode 100644 index 0000000..9c978c9 --- /dev/null +++ b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdCategory.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace RdtClient.Data.Models.Sabnzbd; + +public class SabnzbdCategory +{ + [JsonPropertyName("name")] + public String Name { get; set; } = "default"; + + [JsonPropertyName("order")] + public Int32 Order { get; set; } = 0; + + [JsonPropertyName("dir")] + public String Dir { get; set; } = ""; + + [JsonPropertyName("newzbin")] + public String Newzbin { get; set; } = ""; + + [JsonPropertyName("priority")] + public Int32 Priority { get; set; } = -100; + + [JsonPropertyName("script")] + public String Script { get; set; } = "None"; +} \ No newline at end of file diff --git a/server/RdtClient.Data/Models/Sabnzbd/SabnzbdConfig.cs b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdConfig.cs new file mode 100644 index 0000000..e1605b5 --- /dev/null +++ b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace RdtClient.Data.Models.Sabnzbd; + +public class SabnzbdConfig +{ + [JsonPropertyName("misc")] + public SabnzbdMisc Misc { get; set; } = new(); + + [JsonPropertyName("categories")] + public List Categories { get; set; } = new(); + + [JsonPropertyName("servers")] + public List Servers { get; set; } = new(); +} \ No newline at end of file diff --git a/server/RdtClient.Data/Models/Sabnzbd/SabnzbdHistory.cs b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdHistory.cs new file mode 100644 index 0000000..85b0f72 --- /dev/null +++ b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdHistory.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace RdtClient.Data.Models.Sabnzbd; + +public class SabnzbdHistory +{ + [JsonPropertyName("total_slots")] + public Int32 TotalSlots { get; set; } + + [JsonPropertyName("noofslots")] + public Int32 NoOfSlots { get; set; } + + [JsonPropertyName("slots")] + public List Slots { get; set; } = new(); +} \ No newline at end of file diff --git a/server/RdtClient.Data/Models/Sabnzbd/SabnzbdHistorySlot.cs b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdHistorySlot.cs new file mode 100644 index 0000000..2ffa6ad --- /dev/null +++ b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdHistorySlot.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace RdtClient.Data.Models.Sabnzbd; + +public class SabnzbdHistorySlot +{ + [JsonPropertyName("nzo_id")] + public String NzoId { get; set; } = ""; + + [JsonPropertyName("name")] + public String Name { get; set; } = ""; + + [JsonPropertyName("size")] + public String Size { get; set; } = "0 B"; + + [JsonPropertyName("status")] + public String Status { get; set; } = "Completed"; + + [JsonPropertyName("category")] + public String Category { get; set; } = "Default"; + + [JsonPropertyName("storage")] + public String Path { get; set; } = ""; +} \ No newline at end of file diff --git a/server/RdtClient.Data/Models/Sabnzbd/SabnzbdMisc.cs b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdMisc.cs new file mode 100644 index 0000000..7b028f2 --- /dev/null +++ b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdMisc.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace RdtClient.Data.Models.Sabnzbd; + +public class SabnzbdMisc +{ + [JsonPropertyName("complete_dir")] + public String CompleteDir { get; set; } = ""; + + [JsonPropertyName("download_dir")] + public String DownloadDir { get; set; } = ""; + + [JsonPropertyName("port")] + public String Port { get; set; } = "6500"; + + [JsonPropertyName("version")] + public String Version { get; set; } = "4.4.0"; +} \ No newline at end of file diff --git a/server/RdtClient.Data/Models/Sabnzbd/SabnzbdQueue.cs b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdQueue.cs new file mode 100644 index 0000000..554eeb3 --- /dev/null +++ b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdQueue.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace RdtClient.Data.Models.Sabnzbd; + +public class SabnzbdQueue +{ + [JsonPropertyName("version")] + public String Version { get; set; } = "4.4.0"; + + [JsonPropertyName("status")] + public String Status { get; set; } = "Idle"; + + [JsonPropertyName("speed")] + public String Speed { get; set; } = "0 "; + + [JsonPropertyName("size")] + public String Size { get; set; } = "0 B"; + + [JsonPropertyName("sizeleft")] + public String SizeLeft { get; set; } = "0 B"; + + [JsonPropertyName("noofslots")] + public Int32 NoOfSlots { get; set; } + + [JsonPropertyName("slots")] + public List Slots { get; set; } = new(); +} \ No newline at end of file diff --git a/server/RdtClient.Data/Models/Sabnzbd/SabnzbdQueueSlot.cs b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdQueueSlot.cs new file mode 100644 index 0000000..9bf8a79 --- /dev/null +++ b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdQueueSlot.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace RdtClient.Data.Models.Sabnzbd; + +public class SabnzbdQueueSlot +{ + [JsonPropertyName("index")] + public Int32 Index { get; set; } + + [JsonPropertyName("nzo_id")] + public String NzoId { get; set; } = ""; + + [JsonPropertyName("filename")] + public String Filename { get; set; } = ""; + + [JsonPropertyName("size")] + public String Size { get; set; } = "0 B"; + + [JsonPropertyName("sizeleft")] + public String SizeLeft { get; set; } = "0 B"; + + [JsonPropertyName("percentage")] + public String Percentage { get; set; } = "0"; + + [JsonPropertyName("status")] + public String Status { get; set; } = "Downloading"; + + [JsonPropertyName("cat")] + public String Category { get; set; } = "Default"; + + [JsonPropertyName("timeleft")] + public String TimeLeft { get; set; } = "0:00:00"; + + [JsonPropertyName("priority")] + public String Priority { get; set; } = "Normal"; +} \ No newline at end of file diff --git a/server/RdtClient.Data/Models/Sabnzbd/SabnzbdResponse.cs b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdResponse.cs new file mode 100644 index 0000000..0c00b9e --- /dev/null +++ b/server/RdtClient.Data/Models/Sabnzbd/SabnzbdResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace RdtClient.Data.Models.Sabnzbd; + +public class SabnzbdResponse +{ + [JsonPropertyName("queue")] + public SabnzbdQueue? Queue { get; set; } + + [JsonPropertyName("history")] + public SabnzbdHistory? History { get; set; } + + [JsonPropertyName("version")] + public String? Version { get; set; } + + [JsonPropertyName("status")] + public Boolean? Status { get; set; } + + [JsonPropertyName("error")] + public String? Error { get; set; } + + [JsonPropertyName("nzo_ids")] + public List? NzoIds { get; set; } + + [JsonPropertyName("config")] + public SabnzbdConfig? Config { get; set; } + + [JsonPropertyName("categories")] + public List? Categories { get; set; } +} \ No newline at end of file diff --git a/server/RdtClient.Service.Test/BackgroundServices/WatchFolderCheckerTests.cs b/server/RdtClient.Service.Test/BackgroundServices/WatchFolderCheckerTests.cs new file mode 100644 index 0000000..f900398 --- /dev/null +++ b/server/RdtClient.Service.Test/BackgroundServices/WatchFolderCheckerTests.cs @@ -0,0 +1,213 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using RdtClient.Data.Data; +using RdtClient.Data.Models.Data; +using RdtClient.Service.BackgroundServices; +using RdtClient.Service.Services; +using System.Reflection; + +namespace RdtClient.Service.Test.BackgroundServices; + +public class WatchFolderCheckerTests : IDisposable +{ + private readonly Mock> _loggerMock; + private readonly Mock _serviceProviderMock; + private readonly Mock _serviceScopeMock; + private readonly Mock _scopeServiceProviderMock; + private readonly Mock _torrentsServiceMock; + private readonly String _testPath; + + public WatchFolderCheckerTests() + { + _loggerMock = new Mock>(); + _serviceProviderMock = new Mock(); + _serviceScopeMock = new Mock(); + _scopeServiceProviderMock = new Mock(); + _torrentsServiceMock = new Mock(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); + + _serviceProviderMock + .Setup(x => x.GetService(typeof(IServiceScopeFactory))) + .Returns(new MockScopeFactory(_serviceScopeMock.Object)); + + _serviceScopeMock + .Setup(x => x.ServiceProvider) + .Returns(_scopeServiceProviderMock.Object); + + _scopeServiceProviderMock + .Setup(x => x.GetService(typeof(Torrents))) + .Returns(_torrentsServiceMock.Object); + + _testPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testPath); + + // Reset Settings and Startup + SetStartupReady(true); + ResetSettings(); + Settings.Get.Watch.Path = _testPath; + } + + public void Dispose() + { + if (Directory.Exists(_testPath)) + { + Directory.Delete(_testPath, true); + } + } + + private static void SetStartupReady(Boolean ready) + { + var property = typeof(Startup).GetProperty("Ready", BindingFlags.Public | BindingFlags.Static); + property?.SetValue(null, ready); + } + + private static void ResetSettings() + { + var settings = new Data.Models.Internal.DbSettings + { + Watch = new Data.Models.Internal.DbSettingsWatch + { + Interval = 0, + Default = new Data.Models.Internal.DbSettingsDefaultsWithCategory() + }, + DownloadClient = new Data.Models.Internal.DbSettingsDownloadClient() + }; + + var property = typeof(SettingData).GetProperty("Get", BindingFlags.Public | BindingFlags.Static); + property?.SetValue(null, settings); + } + + private static void ResetPrevCheck(WatchFolderChecker checker) + { + var field = typeof(WatchFolderChecker).GetField("_prevCheck", BindingFlags.NonPublic | BindingFlags.Instance); + field?.SetValue(checker, DateTime.MinValue); + } + + [Fact] + public async Task ExecuteAsync_ShouldProcessTorrentFile() + { + // Arrange + var filePath = Path.Combine(_testPath, "test.torrent"); + var content = "torrent content"u8.ToArray(); + await File.WriteAllBytesAsync(filePath, content); + + var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object); + ResetPrevCheck(checker); + + var cts = new CancellationTokenSource(); + + _torrentsServiceMock + .Setup(x => x.AddFileToDebridQueue(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Torrent()); + + // Act + var task = checker.StartAsync(cts.Token); + await Task.Delay(500); // Give it some time to process + await cts.CancelAsync(); + try { await task; } catch (OperationCanceledException) { } + + // Assert + _torrentsServiceMock.Verify(x => x.AddFileToDebridQueue( + It.Is(b => b.SequenceEqual(content)), + It.IsAny()), Times.AtLeastOnce); + + Assert.False(File.Exists(filePath)); + Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.torrent"))); + } + + [Fact] + public async Task ExecuteAsync_ShouldProcessMagnetFile() + { + // Arrange + var filePath = Path.Combine(_testPath, "test.magnet"); + var content = "magnet:?xt=urn:btih:123"; + await File.WriteAllTextAsync(filePath, content); + + var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object); + ResetPrevCheck(checker); + + var cts = new CancellationTokenSource(); + + _torrentsServiceMock + .Setup(x => x.AddMagnetToDebridQueue(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Torrent()); + + // Act + var task = checker.StartAsync(cts.Token); + await Task.Delay(500); + await cts.CancelAsync(); + try { await task; } catch (OperationCanceledException) { } + + // Assert + _torrentsServiceMock.Verify(x => x.AddMagnetToDebridQueue( + content, + It.IsAny()), Times.AtLeastOnce); + + Assert.False(File.Exists(filePath)); + Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.magnet"))); + } + + [Fact] + public async Task ExecuteAsync_ShouldProcessNzbFile() + { + // Arrange + var filePath = Path.Combine(_testPath, "test.nzb"); + var content = "nzb content"u8.ToArray(); + await File.WriteAllBytesAsync(filePath, content); + + var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object); + ResetPrevCheck(checker); + + var cts = new CancellationTokenSource(); + + _torrentsServiceMock + .Setup(x => x.AddNzbFileToDebridQueue(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Torrent()); + + // Act + var task = checker.StartAsync(cts.Token); + await Task.Delay(500); + await cts.CancelAsync(); + try { await task; } catch (OperationCanceledException) { } + + // Assert + _torrentsServiceMock.Verify(x => x.AddNzbFileToDebridQueue( + It.Is(b => b.SequenceEqual(content)), + "test.nzb", + It.IsAny()), Times.AtLeastOnce); + + Assert.False(File.Exists(filePath)); + Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.nzb"))); + } + + [Fact] + public async Task ExecuteAsync_ShouldIgnoreOtherFiles() + { + // Arrange + var filePath = Path.Combine(_testPath, "test.txt"); + await File.WriteAllTextAsync(filePath, "ignore me"); + + var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object); + ResetPrevCheck(checker); + + var cts = new CancellationTokenSource(); + + // Act + var task = checker.StartAsync(cts.Token); + await Task.Delay(500); + await cts.CancelAsync(); + try { await task; } catch (OperationCanceledException) { } + + // Assert + _torrentsServiceMock.Verify(x => x.AddFileToDebridQueue(It.IsAny(), It.IsAny()), Times.Never); + _torrentsServiceMock.Verify(x => x.AddMagnetToDebridQueue(It.IsAny(), It.IsAny()), Times.Never); + _torrentsServiceMock.Verify(x => x.AddNzbFileToDebridQueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + + Assert.True(File.Exists(filePath)); + } + + private class MockScopeFactory(IServiceScope scope) : IServiceScopeFactory + { + public IServiceScope CreateScope() => scope; + } +} diff --git a/server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs b/server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs index b48f690..50af2cf 100644 --- a/server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs +++ b/server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs @@ -1,7 +1,7 @@ using System.IO.Abstractions.TestingHelpers; using System.Text.Json; using RdtClient.Data.Models.Data; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; using RdtClient.Service.Helpers; namespace RdtClient.Service.Test.Helpers; @@ -183,7 +183,7 @@ public class DownloadHelperTest var fileRelativePath = "inside/lots/of/subdirectories/file.txt"; - IList files = + IList files = [ new() { @@ -219,7 +219,7 @@ public class DownloadHelperTest var fileRelativePath = "inside/lots/of/subdirectories/file.txt"; - IList files = + IList files = [ new() { diff --git a/server/RdtClient.Service.Test/Helpers/FileHelperTest.cs b/server/RdtClient.Service.Test/Helpers/FileHelperTest.cs new file mode 100644 index 0000000..14cb8fd --- /dev/null +++ b/server/RdtClient.Service.Test/Helpers/FileHelperTest.cs @@ -0,0 +1,101 @@ +using RdtClient.Service.Helpers; + +namespace RdtClient.Service.Test.Helpers; + +public class FileHelperTest +{ + [Fact] + public void RemoveInvalidFileNameChars_RemovesInvalidChars() + { + // Arrange + var invalidChars = Path.GetInvalidFileNameChars(); + var input = "test" + new String(invalidChars) + "file.txt"; + + // Act + var result = FileHelper.RemoveInvalidFileNameChars(input); + + // Assert + Assert.Equal("testfile.txt", result); + } + + [Fact] + public void RemoveInvalidFileNameChars_RemovesDirectorySeparators() + { + // Arrange + var input = "folder/subfolder\\file.txt"; + + // Act + var result = FileHelper.RemoveInvalidFileNameChars(input); + + // Assert + Assert.Equal("foldersubfolderfile.txt", result); + } + + [Fact] + public void RemoveInvalidFileNameChars_RemovesDoubleDots() + { + // Arrange + var input = "test..file.txt"; + + // Act + var result = FileHelper.RemoveInvalidFileNameChars(input); + + // Assert + Assert.Equal("testfile.txt", result); + } + + [Fact] + public void RemoveInvalidFileNameChars_TrimsLeadingSeparators() + { + // Arrange + var input = "/test/file.txt"; + + // Act + var result = FileHelper.RemoveInvalidFileNameChars(input); + + // Assert + // Note: The method first splits by invalid chars (including /), + // so "/test/file.txt" becomes "testfile.txt" through String.Concat(Split). + // Then it trims leading separators. + Assert.Equal("testfile.txt", result); + } + + [Fact] + public void RemoveInvalidFileNameChars_HandlesValidFileName() + { + // Arrange + var input = "valid_file-123.txt"; + + // Act + var result = FileHelper.RemoveInvalidFileNameChars(input); + + // Assert + Assert.Equal("valid_file-123.txt", result); + } + + [Fact] + public void RemoveInvalidFileNameChars_HandlesEmptyString() + { + // Arrange + var input = ""; + + // Act + var result = FileHelper.RemoveInvalidFileNameChars(input); + + // Assert + Assert.Equal("", result); + } + + [Theory] + [InlineData("test/../file.txt", "testfile.txt")] + [InlineData(".../test.txt", ".test.txt")] + [InlineData("test....txt", "testtxt")] + public void RemoveInvalidFileNameChars_ComplexCases(String input, String expected) + { + // Act + var result = FileHelper.RemoveInvalidFileNameChars(input); + + // Assert + Assert.Equal(expected, result); + } +} diff --git a/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj b/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj index 638c85a..1bc6622 100644 --- a/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj +++ b/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj @@ -20,6 +20,7 @@ + all @@ -35,4 +36,10 @@ + + + ..\..\..\torbox-net\TorBoxNET\bin\Release\netstandard2.0\TorBoxNET.dll + + + diff --git a/server/RdtClient.Service.Test/Services/NzbTorrentsTest.cs b/server/RdtClient.Service.Test/Services/NzbTorrentsTest.cs new file mode 100644 index 0000000..906e810 --- /dev/null +++ b/server/RdtClient.Service.Test/Services/NzbTorrentsTest.cs @@ -0,0 +1,135 @@ +using System.IO.Abstractions.TestingHelpers; +using System.Text; +using Moq; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; +using TorrentsService = RdtClient.Service.Services.Torrents; + +namespace RdtClient.Service.Test.Services; + +public class NzbTorrentsTest +{ + private readonly Mocks _mocks; + private readonly MockFileSystem _fileSystem; + private readonly TorrentsService _torrents; + + public NzbTorrentsTest() + { + _mocks = new Mocks(); + _fileSystem = new MockFileSystem(); + _torrents = new TorrentsService( + _mocks.TorrentsLoggerMock.Object, + _mocks.TorrentDataMock.Object, + _mocks.DownloadsMock.Object, + _mocks.ProcessFactoryMock.Object, + _fileSystem, + _mocks.EnricherMock.Object, + null!, + null!, + null!, + null!, + null! + ); + } + + [Fact] + public async Task AddNzbLinkToDebridQueue_ValidLink_AddsToQueue() + { + // Arrange + var nzbLink = "http://example.com/test.nzb"; + var torrent = new Torrent + { + DownloadClient = DownloadClient.Bezzad + }; + + _mocks.TorrentDataMock.Setup(t => t.GetByHash(It.IsAny())).ReturnsAsync((Torrent)null!); + _mocks.TorrentDataMock.Setup(t => t.Add( + null, + It.IsAny(), + nzbLink, + false, + DownloadType.Nzb, + torrent.DownloadClient, + It.IsAny() + )).ReturnsAsync(new Torrent { Hash = "mockHash", RdName = "test.nzb" }); + + // Act + var result = await _torrents.AddNzbLinkToDebridQueue(nzbLink, torrent); + + // Assert + Assert.NotNull(result); + Assert.Equal("test.nzb", torrent.RdName); + Assert.Equal(TorrentStatus.Queued, torrent.RdStatus); + _mocks.TorrentDataMock.Verify(t => t.Add( + null, + It.IsAny(), + nzbLink, + false, + DownloadType.Nzb, + torrent.DownloadClient, + torrent + ), Times.Once); + } + + [Fact] + public async Task AddNzbLinkToDebridQueue_InvalidLink_ThrowsException() + { + // Arrange + var nzbLink = "invalid-link"; + var torrent = new Torrent(); + + // Act & Assert + await Assert.ThrowsAsync(() => _torrents.AddNzbLinkToDebridQueue(nzbLink, torrent)); + } + + [Fact] + public async Task AddNzbFileToDebridQueue_ValidFile_AddsToQueue() + { + // Arrange + var nzbContent = "\r\n\r\n \r\n Test NZB Title\r\n \r\n"; + var bytes = Encoding.UTF8.GetBytes(nzbContent); + var torrent = new Torrent + { + DownloadClient = DownloadClient.Bezzad + }; + + _mocks.TorrentDataMock.Setup(t => t.GetByHash(It.IsAny())).ReturnsAsync((Torrent)null!); + _mocks.TorrentDataMock.Setup(t => t.Add( + null, + It.IsAny(), + It.IsAny(), + true, + DownloadType.Nzb, + torrent.DownloadClient, + It.IsAny() + )).ReturnsAsync(new Torrent { Hash = "mockHash", RdName = "Test NZB Title" }); + + // Act + var result = await _torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test NZB Title", torrent.RdName); + Assert.Equal(TorrentStatus.Queued, torrent.RdStatus); + _mocks.TorrentDataMock.Verify(t => t.Add( + null, + It.IsAny(), + Convert.ToBase64String(bytes), + true, + DownloadType.Nzb, + torrent.DownloadClient, + torrent + ), Times.Once); + } + + [Fact] + public async Task AddNzbFileToDebridQueue_InvalidXml_ThrowsException() + { + // Arrange + var bytes = Encoding.UTF8.GetBytes("not xml"); + var torrent = new Torrent(); + + // Act & Assert + await Assert.ThrowsAsync(() => _torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent)); + } +} diff --git a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs new file mode 100644 index 0000000..c2979a1 --- /dev/null +++ b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Logging; +using Moq; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; +using RdtClient.Service.Services; + +namespace RdtClient.Service.Test.Services; + +public class QBittorrentTest +{ + private readonly Mock> _loggerMock; + private readonly Mock _torrentsMock; + private readonly Mock _authenticationMock; + private readonly QBittorrent _qBittorrent; + + public QBittorrentTest() + { + _loggerMock = new Mock>(); + _torrentsMock = new Mock(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); + _authenticationMock = new Mock(null!, null!, null!); + + _qBittorrent = new QBittorrent(_loggerMock.Object, null!, _authenticationMock.Object, _torrentsMock.Object, null!); + } + + [Fact] + public async Task TorrentInfo_ShouldOnlyReturnTorrents() + { + // Arrange + var allTorrents = new List + { + new() + { + TorrentId = Guid.NewGuid(), + Hash = "hash1", + RdName = "Torrent 1", + Type = DownloadType.Torrent + }, + new() + { + TorrentId = Guid.NewGuid(), + Hash = "hash2", + RdName = "NZB 1", + Type = DownloadType.Nzb + } + }; + + _torrentsMock.Setup(m => m.Get()).ReturnsAsync(allTorrents); + + // Act + var result = await _qBittorrent.TorrentInfo(); + + // Assert + Assert.Single(result); + Assert.Equal("hash1", result[0].Hash); + Assert.Equal("Torrent 1", result[0].Name); + } +} diff --git a/server/RdtClient.Service.Test/Services/SabnzbdTest.cs b/server/RdtClient.Service.Test/Services/SabnzbdTest.cs new file mode 100644 index 0000000..85ddcee --- /dev/null +++ b/server/RdtClient.Service.Test/Services/SabnzbdTest.cs @@ -0,0 +1,399 @@ +using Microsoft.Extensions.Logging; +using Moq; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; +using RdtClient.Data.Models.Internal; +using RdtClient.Service.Services; + +namespace RdtClient.Service.Test.Services; + +public class SabnzbdTest +{ + private readonly Mock> _loggerMock = new(); + private readonly Mock _torrentsMock; + private readonly AppSettings _appSettings = new() { Port = 6500 }; + + public SabnzbdTest() + { + _torrentsMock = new Mock(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(new List()); + } + + [Fact] + public async Task GetQueue_ShouldReturnCorrectStructure() + { + // Arrange + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "Name 1", + RdProgress = 50, + Type = DownloadType.Nzb, + Downloads = new List + { + new() + { BytesTotal = 1000, BytesDone = 500 } + } + } + }; + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetQueue(); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Slots); + Assert.Equal("hash1", result.Slots[0].NzoId); + Assert.Equal("Name 1", result.Slots[0].Filename); + // (50% debrid + 50% download) / 2 = 50% + Assert.Equal("50", result.Slots[0].Percentage); + } + + [Fact] + public async Task GetQueue_ShouldCalculatePercentageCorrectly_WhenDifferentProgress() + { + // Arrange + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "Name 1", + RdProgress = 100, + Type = DownloadType.Nzb, + Downloads = new List + { + new() + { BytesTotal = 1000, BytesDone = 0 } + } + } + }; + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetQueue(); + + // Assert + // (100% debrid + 0% download) / 2 = 50% + Assert.Equal("50", result.Slots[0].Percentage); + } + + [Fact] + public async Task GetQueue_ShouldCalculateTimeLeftCorrectly() + { + // Arrange + var now = DateTimeOffset.UtcNow; + var added = now.AddMinutes(-10); + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "Name 1", + Added = added, + RdProgress = 100, // 100% debrid + Type = DownloadType.Nzb, + Downloads = new List + { + new() + { + BytesTotal = 1000, + BytesDone = 0 // 0% download + } + } + } + }; + // Overall progress = (1.0 + 0.0) / 2 = 0.5 + // Elapsed = 10 minutes + // Total estimated = 10 / 0.5 = 20 minutes + // Time left = 20 - 10 = 10 minutes + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetQueue(); + + // Assert + // Allow for some small variation in time calculation during test execution + var timeLeftParts = result.Slots[0].TimeLeft.Split(':'); + var hours = Int32.Parse(timeLeftParts[0]); + var minutes = Int32.Parse(timeLeftParts[1]); + + Assert.Equal(0, hours); + Assert.InRange(minutes, 9, 11); + } + + [Fact] + public async Task GetQueue_ShouldCalculateTimeLeftCorrectly_WithRetry() + { + // Arrange + var now = DateTimeOffset.UtcNow; + var added = now.AddMinutes(-20); + var retry = now.AddMinutes(-10); + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "Name 1", + Added = added, + Retry = retry, + RdProgress = 100, // 100% debrid + Type = DownloadType.Nzb, + Downloads = new List + { + new() + { + BytesTotal = 1000, + BytesDone = 0 // 0% download + } + } + } + }; + // Later of Added and Retry is Retry (-10 mins) + // Overall progress = (1.0 + 0.0) / 2 = 0.5 + // Elapsed = 10 minutes + // Total estimated = 10 / 0.5 = 20 minutes + // Time left = 20 - 10 = 10 minutes + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetQueue(); + + // Assert + var timeLeftParts = result.Slots[0].TimeLeft.Split(':'); + var hours = Int32.Parse(timeLeftParts[0]); + var minutes = Int32.Parse(timeLeftParts[1]); + + Assert.Equal(0, hours); + Assert.InRange(minutes, 9, 11); + } + + [Fact] + public async Task GetQueue_ShouldOnlyReturnNzbs() + { + // Arrange + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "NZB 1", + Type = DownloadType.Nzb, + Downloads = new List() + }, + new() + { + Hash = "hash2", + RdName = "Torrent 1", + Type = DownloadType.Torrent, + Downloads = new List() + } + }; + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetQueue(); + + // Assert + Assert.Single(result.Slots); + Assert.Equal("hash1", result.Slots[0].NzoId); + } + + [Fact] + public async Task GetHistory_ShouldOnlyReturnNzbs() + { + // Arrange + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "NZB 1", + Type = DownloadType.Nzb, + Completed = DateTimeOffset.UtcNow, + Downloads = new List() + }, + new() + { + Hash = "hash2", + RdName = "Torrent 1", + Type = DownloadType.Torrent, + Completed = DateTimeOffset.UtcNow, + Downloads = new List() + } + }; + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetHistory(); + + // Assert + Assert.Single(result.Slots); + Assert.Equal("hash1", result.Slots[0].NzoId); + } + + [Fact] + public async Task GetHistory_ShouldReturnFullPath() + { + // Arrange + var savePath = @"C:\Downloads"; + Data.Data.SettingData.Get.DownloadClient.MappedPath = savePath; + + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "NZB 1", + Category = "radarr", + Type = DownloadType.Nzb, + Completed = DateTimeOffset.UtcNow, + Downloads = new List() + } + }; + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetHistory(); + + // Assert + Assert.Single(result.Slots); + var expectedPath = Path.Combine(savePath, "radarr", "NZB 1"); + Assert.Equal(expectedPath, result.Slots[0].Path); + } + + [Fact] + public async Task GetHistory_ShouldReturnFullPath_NoCategory() + { + // Arrange + var savePath = @"C:\Downloads"; + Data.Data.SettingData.Get.DownloadClient.MappedPath = savePath; + + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "NZB 1", + Category = null, + Type = DownloadType.Nzb, + Completed = DateTimeOffset.UtcNow, + Downloads = new List() + } + }; + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetHistory(); + + // Assert + Assert.Single(result.Slots); + var expectedPath = Path.Combine(savePath, "NZB 1"); + Assert.Equal(expectedPath, result.Slots[0].Path); + } + + [Fact] + public async Task GetHistory_ShouldReturnFailedStatus_WhenTorrentHasError() + { + // Arrange + var torrentList = new List + { + new() + { + Hash = "hash1", + RdName = "NZB 1", + Type = DownloadType.Nzb, + Completed = DateTimeOffset.UtcNow, + Error = "Some error occurred", + Downloads = new List() + } + }; + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = await sabnzbd.GetHistory(); + + // Assert + Assert.Single(result.Slots); + Assert.Equal("Failed", result.Slots[0].Status); + } + + [Fact] + public void GetConfig_ShouldReturnCorrectConfig() + { + // Arrange + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = sabnzbd.GetConfig(); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Misc); + Assert.Equal("6500", result.Misc.Port); + Assert.NotEmpty(result.Categories); + Assert.Contains(result.Categories, c => c.Name == "*"); + } + + [Fact] + public void GetCategories_ShouldOnlyReturnSettingsCategories() + { + // Arrange + var torrentList = new List + { + new() + { + Hash = "hash1", + Category = "Movie", + Type = DownloadType.Nzb, + Downloads = new List() + } + }; + + _torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList); + + Data.Data.SettingData.Get.General.Categories = "TV, Music, *"; + + var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings); + + // Act + var result = sabnzbd.GetCategories(); + + // Assert + Assert.Equal("*", result[0]); + Assert.Contains("TV", result); + Assert.Contains("Music", result); + Assert.DoesNotContain("Movie", result); + Assert.Equal(3, result.Count); + } +} diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridDebridClientTest.cs similarity index 82% rename from server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs rename to server/RdtClient.Service.Test/Services/TorrentClients/AllDebridDebridClientTest.cs index f494aca..888cba1 100644 --- a/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs +++ b/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridDebridClientTest.cs @@ -5,12 +5,12 @@ using Newtonsoft.Json; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Service.Services; -using RdtClient.Service.Services.TorrentClients; +using RdtClient.Service.Services.DebridClients; using File = AllDebridNET.File; namespace RdtClient.Service.Test.Services.TorrentClients; -public class AllDebridTorrentClientTest +public class AllDebridDebridClientTest { private static readonly Magnet Magnet1HalfDownloaded = new() { @@ -84,10 +84,10 @@ public class AllDebridTorrentClientTest Fullsync = true }); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act - var result = await allDebridTorrentClient.GetTorrents(); + var result = await allDebridTorrentClient.GetDownloads(); // Assert Assert.NotNull(result); @@ -114,10 +114,10 @@ public class AllDebridTorrentClientTest Fullsync = false }); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act - var result = await allDebridTorrentClient.GetTorrents(); + var result = await allDebridTorrentClient.GetDownloads(); // Assert Assert.NotNull(result); @@ -144,15 +144,15 @@ public class AllDebridTorrentClientTest Fullsync = true }); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act - // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // `GetDownloads` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, // so when the second call `_cache.Add`s, it also affects `firstResult`. // `.ToList()` clones it so it won't be changed by the second call - var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); - var secondResult = await allDebridTorrentClient.GetTorrents(); + var firstResult = (await allDebridTorrentClient.GetDownloads()).ToList(); + var secondResult = await allDebridTorrentClient.GetDownloads(); // Assert Assert.Single(firstResult); @@ -180,15 +180,15 @@ public class AllDebridTorrentClientTest Fullsync = false }); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act - // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // `GetDownloads` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, // so when the second call `_cache.Add`s, it also affects `firstResult`. // `.ToList()` clones it so it won't be changed by the second call - var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); - var secondResult = await allDebridTorrentClient.GetTorrents(); + var firstResult = (await allDebridTorrentClient.GetDownloads()).ToList(); + var secondResult = await allDebridTorrentClient.GetDownloads(); // Assert Assert.Single(firstResult); @@ -222,15 +222,15 @@ public class AllDebridTorrentClientTest Fullsync = false }); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act - // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // `GetDownloads` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, // so when the second call `_cache.Add`s, it also affects `firstResult`. // `.ToList()` clones it so it won't be changed by the second call - var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); - var secondResult = await allDebridTorrentClient.GetTorrents(); + var firstResult = (await allDebridTorrentClient.GetDownloads()).ToList(); + var secondResult = await allDebridTorrentClient.GetDownloads(); // Assert Assert.Equal(2, firstResult.Count); @@ -269,12 +269,12 @@ public class AllDebridTorrentClientTest Fullsync = true }); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act - // We have to use `GetTorrents` since `Map` is private - var result = await allDebridTorrentClient.GetTorrents(); + // We have to use `GetDownloads` since `Map` is private + var result = await allDebridTorrentClient.GetDownloads(); // Assert Assert.Equal(expectedProgress, result.First().Progress); @@ -294,7 +294,7 @@ public class AllDebridTorrentClientTest }; var serializedOriginal = JsonConvert.SerializeObject(torrent); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act var result = await allDebridTorrentClient.UpdateData(torrent, null); @@ -320,7 +320,7 @@ public class AllDebridTorrentClientTest }; mocks.AllDebridClientMock.Setup(c => c.Magnet.StatusAsync(torrent.RdId, It.IsAny())).ReturnsAsync(Magnet1Finished); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act var result = await allDebridTorrentClient.UpdateData(torrent, null); @@ -353,7 +353,7 @@ public class AllDebridTorrentClientTest mocks.AllDebridClientMock.Setup(c => c.Magnet.StatusAsync(torrent.RdId, It.IsAny())) .ThrowsAsync(new AllDebridException("Magnet not found", "MAGNET_INVALID_ID")); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act var result = await allDebridTorrentClient.UpdateData(torrent, null); @@ -388,8 +388,8 @@ public class AllDebridTorrentClientTest Fullsync = true }); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); - var torrentClientTorrent = (await allDebridTorrentClient.GetTorrents()).First(); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var torrentClientTorrent = (await allDebridTorrentClient.GetDownloads()).First(); // Act var result = await allDebridTorrentClient.UpdateData(torrent, torrentClientTorrent); @@ -415,7 +415,7 @@ public class AllDebridTorrentClientTest RdId = null }; - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act var result = await allDebridTorrentClient.GetDownloadInfos(torrent); @@ -464,7 +464,7 @@ public class AllDebridTorrentClientTest mocks.FileFilterMock.Setup(f => f.IsDownloadable(torrent, "file1.txt", 123)).Returns(true); mocks.FileFilterMock.Setup(f => f.IsDownloadable(torrent, "folder/file2.txt", 180)).Returns(false); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act var result = await allDebridTorrentClient.GetDownloadInfos(torrent); @@ -505,7 +505,7 @@ public class AllDebridTorrentClientTest mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(files); mocks.FileFilterMock.Setup(f => f.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(false); - var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); + var allDebridTorrentClient = new AllDebridDebridClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object, mocks.FileFilterMock.Object); // Act var result = await allDebridTorrentClient.GetDownloadInfos(torrent); @@ -522,8 +522,8 @@ public class AllDebridTorrentClientTest { public readonly Mock AllDebridClientFactoryMock; public readonly Mock AllDebridClientMock; + public readonly Mock> LoggerMock; public readonly Mock FileFilterMock; - public readonly Mock> LoggerMock; public Mocks() { diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs new file mode 100644 index 0000000..30d6ddd --- /dev/null +++ b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs @@ -0,0 +1,481 @@ +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; +using Newtonsoft.Json; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; +using RdtClient.Data.Models.DebridClient; +using RdtClient.Service.Services; +using RdtClient.Service.Services.DebridClients; +using TorBoxNET; + +namespace RdtClient.Service.Test.Services.TorrentClients; + +public class TorBoxDebridClientTest +{ + private readonly Mock> _loggerMock; + private readonly Mock _httpClientFactoryMock; + private readonly Mock _fileFilterMock; + + public TorBoxDebridClientTest() + { + _loggerMock = new Mock>(); + _httpClientFactoryMock = new Mock(); + _fileFilterMock = new Mock(); + + var httpClient = new HttpClient(); + _httpClientFactoryMock.Setup(m => m.CreateClient(It.IsAny())).Returns(httpClient); + + Settings.Get.Provider.ApiKey = "test-api-key"; + Settings.Get.Provider.Timeout = 100; + } + + [Fact] + public async Task GetDownloads_ReturnsTorrentsAndNzbsWithCorrectType() + { + // Arrange + var torrents = new List + { + new() { Hash = "hash1", Name = "torrent1", Size = 1000, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow } + }; + var nzbs = new List + { + new() { Hash = "hash2", Name = "nzb1", Size = 2000, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow } + }; + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + clientMock.Protected().Setup?>>("GetCurrentTorrents").ReturnsAsync(torrents); + clientMock.Protected().Setup?>>("GetQueuedTorrents").ReturnsAsync(new List()); + clientMock.Protected().Setup?>>("GetCurrentUsenet").ReturnsAsync(nzbs); + clientMock.Protected().Setup?>>("GetQueuedUsenet").ReturnsAsync(new List()); + + // Act + var result = await clientMock.Object.GetDownloads(); + + // Assert + Assert.Equal(2, result.Count); + + var torrentResult = result.FirstOrDefault(r => r.Id == "hash1"); + Assert.NotNull(torrentResult); + Assert.Equal(DownloadType.Torrent, torrentResult.Type); + + var nzbResult = result.FirstOrDefault(r => r.Id == "hash2"); + Assert.NotNull(nzbResult); + Assert.Equal(DownloadType.Nzb, nzbResult.Type); + } + + [Fact] + public async Task GetAvailableFiles_ReturnsTorrentFiles_WhenTorrentFound() + { + // Arrange + var hash = "test-hash"; + var availability = new Response> + { + Data = new List + { + new() + { + Files = new List + { + new() { Name = "file1.mkv", Size = 100 }, + new() { Name = "file2.txt", Size = 10 } + } + } + } + }; + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(availability); + + // Act + var result = await clientMock.Object.GetAvailableFiles(hash); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("file1.mkv", result[0].Filename); + Assert.Equal(100, result[0].Filesize); + Assert.Equal("file2.txt", result[1].Filename); + Assert.Equal(10, result[1].Filesize); + } + + [Fact] + public async Task GetAvailableFiles_ReturnsUsenetFiles_WhenTorrentNotFoundButUsenetFound() + { + // Arrange + var hash = "test-hash"; + var torrentAvailability = new Response> { Data = new List() }; + var usenetAvailability = new Response> + { + Data = new List + { + new() + { + Files = new List + { + new() { Name = "file1.nzb", Size = 200 } + } + } + } + }; + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability); + clientMock.Protected().Setup>>>("GetUsenetAvailability", hash).ReturnsAsync(usenetAvailability); + + // Act + var result = await clientMock.Object.GetAvailableFiles(hash); + + // Assert + Assert.Single(result); + Assert.Equal("file1.nzb", result[0].Filename); + Assert.Equal(200, result[0].Filesize); + } + + [Fact] + public async Task GetAvailableFiles_ReturnsEmptyList_WhenNeitherFound() + { + // Arrange + var hash = "test-hash"; + var torrentAvailability = new Response> { Data = new List() }; + var usenetAvailability = new Response> { Data = new List() }; + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability); + clientMock.Protected().Setup>>>("GetUsenetAvailability", hash).ReturnsAsync(usenetAvailability); + + // Act + var result = await clientMock.Object.GetAvailableFiles(hash); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task Delete_CallsTorrentsControl_WhenTypeIsTorrent() + { + // Arrange + var torrent = new Torrent + { + RdId = "torrent-id", + Type = DownloadType.Torrent + }; + + var torrentsApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + // Act + await clientMock.Object.Delete(torrent); + + // Assert + torrentsApiMock.Verify(m => m.ControlAsync("torrent-id", "delete", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_CallsUsenetControl_WhenTypeIsNzb() + { + // Arrange + var torrent = new Torrent + { + RdId = "nzb-id", + Type = DownloadType.Nzb + }; + + var usenetApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + // Act + await clientMock.Object.Delete(torrent); + + // Assert + usenetApiMock.Verify(m => m.ControlAsync("nzb-id", "delete", false, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Unrestrict_CallsTorrentsRequestDownload_WhenTypeIsTorrent() + { + // Arrange + var torrent = new Torrent + { + RdId = "torrent-id", + Type = DownloadType.Torrent + }; + var link = "https://torbox.app/d/123/456"; + + var torrentsApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + torrentsApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny())) + .ReturnsAsync(new Response { Data = "https://unrestricted-link" }); + + // Act + var result = await clientMock.Object.Unrestrict(torrent, link); + + // Assert + Assert.Equal("https://unrestricted-link", result); + torrentsApiMock.Verify(m => m.RequestDownloadAsync(123, 456, false, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Unrestrict_CallsUsenetRequestDownload_WhenTypeIsNzb() + { + // Arrange + var torrent = new Torrent + { + RdId = "nzb-id", + Type = DownloadType.Nzb + }; + var link = "https://torbox.app/d/123/456"; + + var usenetApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + usenetApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny())) + .ReturnsAsync(new Response { Data = "https://unrestricted-link-nzb" }); + + // Act + var result = await clientMock.Object.Unrestrict(torrent, link); + + // Assert + Assert.Equal("https://unrestricted-link-nzb", result); + usenetApiMock.Verify(m => m.RequestDownloadAsync(123, 456, false, It.IsAny()), Times.Once); + } + + [Fact] + public async Task AddNzbFile_CallsUsenetAddFileAsyncWithName() + { + // Arrange + var bytes = new Byte[] { 1, 2, 3 }; + var name = "test-nzb"; + var usenetApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true }; + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + usenetApiMock.Setup(m => m.AddFileAsync(bytes, -1, name, null, It.IsAny())) + .ReturnsAsync(new Response { Data = new UsenetAddResult { Hash = "new-hash" } }); + + // Act + var result = await clientMock.Object.AddNzbFile(bytes, name); + + // Assert + Assert.Equal("new-hash", result); + usenetApiMock.Verify(m => m.AddFileAsync(bytes, -1, name, null, It.IsAny()), Times.Once); + } + [Fact] + public async Task GetDownloadInfos_GeneratesCorrectFakedlLinks_ForIndividualFiles() + { + // Arrange + var files = new List + { + new() { Id = 1, Path = "file1.mkv", Bytes = 1000 }, + new() { Id = 2, Path = "file2.mkv", Bytes = 2000 } + }; + var torrent = new Torrent + { + Hash = "test-hash", + RdFiles = JsonConvert.SerializeObject(files) + }; + + var torrentsApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny())) + .ReturnsAsync(new TorrentInfoResult { Id = 12345 }); + + _fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(true); + + Settings.Get.Provider.PreferZippedDownloads = false; + + // Act + var result = await clientMock.Object.GetDownloadInfos(torrent); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal("https://torbox.app/fakedl/12345/1", result[0].RestrictedLink); + Assert.Equal("https://torbox.app/fakedl/12345/2", result[1].RestrictedLink); + } + + [Fact] + public async Task GetDownloadInfos_GeneratesCorrectFakedlLink_ForZipDownload() + { + // Arrange + var files = new List + { + new() { Id = 1, Path = "file1.mkv", Bytes = 1000 } + }; + var torrent = new Torrent + { + Hash = "test-hash", + RdName = "TestTorrent", + RdFiles = JsonConvert.SerializeObject(files), + DownloadClient = Data.Enums.DownloadClient.Aria2c + }; + + Settings.Get.Provider.PreferZippedDownloads = true; + + var torrentsApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny())) + .ReturnsAsync(new TorrentInfoResult { Id = 12345 }); + + _fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(true); + + // Act + var result = await clientMock.Object.GetDownloadInfos(torrent); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("https://torbox.app/fakedl/12345/zip", result[0].RestrictedLink); + Assert.Equal("TestTorrent.zip", result[0].FileName); + } + + [Fact] + public async Task Unrestrict_ParsesFakedlLinksCorrectly_ForIndividualFiles() + { + // Arrange + var torrent = new Torrent + { + Type = DownloadType.Torrent + }; + var link = "https://torbox.app/fakedl/12345/6789"; + + var torrentsApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 6789, false, It.IsAny())) + .ReturnsAsync(new Response { Data = "https://real-download-link" }); + + // Act + var result = await clientMock.Object.Unrestrict(torrent, link); + + // Assert + Assert.Equal("https://real-download-link", result); + torrentsApiMock.Verify(m => m.RequestDownloadAsync(12345, 6789, false, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Unrestrict_ParsesFakedlLinksCorrectly_ForZipDownload() + { + // Arrange + var torrent = new Torrent + { + Type = DownloadType.Torrent + }; + var link = "https://torbox.app/fakedl/12345/zip"; + + var torrentsApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 0, true, It.IsAny())) + .ReturnsAsync(new Response { Data = "https://real-zip-download-link" }); + + // Act + var result = await clientMock.Object.Unrestrict(torrent, link); + + // Assert + Assert.Equal("https://real-zip-download-link", result); + torrentsApiMock.Verify(m => m.RequestDownloadAsync(12345, 0, true, It.IsAny()), Times.Once); + } + [Fact] + public async Task GetDownloadInfos_GeneratesCorrectFakedlLinks_ForUsenet() + { + // Arrange + var files = new List + { + new() { Id = 1, Path = "file1.nzb", Bytes = 1000 } + }; + var torrent = new Torrent + { + Type = DownloadType.Nzb, + RdId = "nzb-hash", + RdFiles = JsonConvert.SerializeObject(files) + }; + + var usenetApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + usenetApiMock.Setup(m => m.GetCurrentAsync(true, It.IsAny())) + .ReturnsAsync(new List { new() { Hash = "nzb-hash", Id = 98765 } }); + + _fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny(), It.IsAny())).Returns(true); + + Settings.Get.Provider.PreferZippedDownloads = false; + + // Act + var result = await clientMock.Object.GetDownloadInfos(torrent); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("https://torbox.app/fakedl/98765/1", result[0].RestrictedLink); + } + + [Fact] + public async Task Unrestrict_ParsesFakedlLinksCorrectly_ForUsenet() + { + // Arrange + var torrent = new Torrent + { + Type = DownloadType.Nzb + }; + var link = "https://torbox.app/fakedl/98765/4321"; + + var usenetApiMock = new Mock(); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); + clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + + usenetApiMock.Setup(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny())) + .ReturnsAsync(new Response { Data = "https://real-usenet-link" }); + + // Act + var result = await clientMock.Object.Unrestrict(torrent, link); + + // Assert + Assert.Equal("https://real-usenet-link", result); + usenetApiMock.Verify(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny()), Times.Once); + } +} diff --git a/server/RdtClient.Service.Test/Services/TorrentsTest.cs b/server/RdtClient.Service.Test/Services/TorrentsTest.cs index 3b5f844..1b2b5ab 100644 --- a/server/RdtClient.Service.Test/Services/TorrentsTest.cs +++ b/server/RdtClient.Service.Test/Services/TorrentsTest.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Moq; using RdtClient.Data.Data; +using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Internal; using RdtClient.Service.Services; @@ -316,4 +317,95 @@ public class TorrentsTest Assert.Matches("error-line 2", exitedWithOutputMessage); Assert.Matches("error-line 3", exitedWithOutputMessage); } + + [Fact] + public async Task AddNzbFileToDebridQueue_ShouldSetDownloadTypeNzb() + { + // Arrange + var mocks = new Mocks(); + var torrent = new Torrent + { + TorrentId = Guid.NewGuid() + }; + var nzbContent = "\r\n\r\n \r\n Test NZB Title\r\n \r\n"; + var bytes = Encoding.UTF8.GetBytes(nzbContent); + + mocks.TorrentDataMock.Setup(t => t.Add(It.IsAny(), + It.IsAny(), + It.IsAny(), + true, + DownloadType.Nzb, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new Torrent()); + + var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object, + mocks.TorrentDataMock.Object, + mocks.DownloadsMock.Object, + mocks.ProcessFactoryMock.Object, + new MockFileSystem(), + mocks.EnricherMock.Object, + null!, + null!, + null!, + null!, + null!); + + // Act + await torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent); + + // Assert + mocks.TorrentDataMock.Verify(t => t.Add(null, + It.IsAny(), + It.IsAny(), + true, + DownloadType.Nzb, + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task AddNzbLinkToDebridQueue_ShouldSetDownloadTypeNzb() + { + // Arrange + var mocks = new Mocks(); + var torrent = new Torrent + { + TorrentId = Guid.NewGuid() + }; + var link = "http://example.com/test.nzb"; + + mocks.TorrentDataMock.Setup(t => t.Add(It.IsAny(), + It.IsAny(), + It.IsAny(), + false, + DownloadType.Nzb, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new Torrent()); + + var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object, + mocks.TorrentDataMock.Object, + mocks.DownloadsMock.Object, + mocks.ProcessFactoryMock.Object, + new MockFileSystem(), + mocks.EnricherMock.Object, + null!, + null!, + null!, + null!, + null!); + + // Act + await torrents.AddNzbLinkToDebridQueue(link, torrent); + + // Assert + mocks.TorrentDataMock.Verify(t => t.Add(null, + It.IsAny(), + link, + false, + DownloadType.Nzb, + It.IsAny(), + It.IsAny()), Times.Once); + } } diff --git a/server/RdtClient.Service/BackgroundServices/WatchFolderChecker.cs b/server/RdtClient.Service/BackgroundServices/WatchFolderChecker.cs index 7160a59..c287034 100644 --- a/server/RdtClient.Service/BackgroundServices/WatchFolderChecker.cs +++ b/server/RdtClient.Service/BackgroundServices/WatchFolderChecker.cs @@ -28,7 +28,13 @@ public class WatchFolderChecker(ILogger logger, IServiceProv { try { - await Task.Delay(1000, stoppingToken); + var nextCheck = _prevCheck.AddSeconds(Math.Max(Settings.Get.Watch.Interval, 10)); + if (DateTime.Now < nextCheck) + { + var delay = nextCheck - DateTime.Now; + await Task.Delay(delay, stoppingToken); + } + _prevCheck = DateTime.Now; if (String.IsNullOrWhiteSpace(Settings.Get.Watch.Path)) { @@ -48,14 +54,6 @@ public class WatchFolderChecker(ILogger logger, IServiceProv errorStorePath = Settings.Get.Watch.ErrorPath; } - var nextCheck = _prevCheck.AddSeconds(Settings.Get.Watch.Interval); - - if (DateTime.UtcNow < nextCheck) - { - continue; - } - - _prevCheck = DateTime.UtcNow; var torrentFiles = Directory.GetFiles(Settings.Get.Watch.Path, "*.*", SearchOption.TopDirectoryOnly); @@ -63,7 +61,7 @@ public class WatchFolderChecker(ILogger logger, IServiceProv { var fileInfo = new FileInfo(torrentFile); - if (fileInfo.Extension != ".magnet" && fileInfo.Extension != ".torrent") + if (fileInfo.Extension != ".magnet" && fileInfo.Extension != ".torrent" && fileInfo.Extension != ".nzb") { continue; } @@ -107,6 +105,11 @@ public class WatchFolderChecker(ILogger logger, IServiceProv var magnetLink = await File.ReadAllTextAsync(torrentFile, stoppingToken); await torrentService.AddMagnetToDebridQueue(magnetLink, torrent); } + else if (fileInfo.Extension == ".nzb") + { + var nzbFileContents = await File.ReadAllBytesAsync(torrentFile, stoppingToken); + await torrentService.AddNzbFileToDebridQueue(nzbFileContents, fileInfo.Name, torrent); + } if (!Directory.Exists(processedStorePath)) { diff --git a/server/RdtClient.Service/DiConfig.cs b/server/RdtClient.Service/DiConfig.cs index fa8f6d6..a22a266 100644 --- a/server/RdtClient.Service/DiConfig.cs +++ b/server/RdtClient.Service/DiConfig.cs @@ -8,7 +8,7 @@ using Polly.Extensions.Http; using RdtClient.Service.BackgroundServices; using RdtClient.Service.Middleware; using RdtClient.Service.Services; -using RdtClient.Service.Services.TorrentClients; +using RdtClient.Service.Services.DebridClients; using RdtClient.Service.Wrappers; namespace RdtClient.Service; @@ -23,7 +23,7 @@ public static class DiConfig services.AddMemoryCache(); services.AddSingleton(); - services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); @@ -31,12 +31,13 @@ public static class DiConfig services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -46,6 +47,7 @@ public static class DiConfig services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); diff --git a/server/RdtClient.Service/Helpers/CredentialRedactorEnricher.cs b/server/RdtClient.Service/Helpers/CredentialRedactorEnricher.cs new file mode 100644 index 0000000..33d22e5 --- /dev/null +++ b/server/RdtClient.Service/Helpers/CredentialRedactorEnricher.cs @@ -0,0 +1,66 @@ +using Serilog.Core; +using Serilog.Events; +using RdtClient.Service.Services; + +namespace RdtClient.Service.Helpers; + +public class CredentialRedactorEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + var sensitiveValues = new List(); + + var apiKey = Settings.Get.Provider.ApiKey; + if (!String.IsNullOrWhiteSpace(apiKey) && apiKey.Length > 5) + { + sensitiveValues.Add(apiKey); + } + + var aria2Secret = Settings.Get.DownloadClient.Aria2cSecret; + if (!String.IsNullOrWhiteSpace(aria2Secret) && aria2Secret.Length > 5) + { + sensitiveValues.Add(aria2Secret); + } + + var dsPassword = Settings.Get.DownloadClient.DownloadStationPassword; + if (!String.IsNullOrWhiteSpace(dsPassword) && dsPassword.Length > 5) + { + sensitiveValues.Add(dsPassword); + } + + if (sensitiveValues.Count == 0) + { + return; + } + + foreach (var sensitiveValue in sensitiveValues) + { + // Redact in the message template + if (logEvent.MessageTemplate.Text.Contains(sensitiveValue)) + { + var newText = logEvent.MessageTemplate.Text.Replace(sensitiveValue, "*****"); + + var field = typeof(MessageTemplate).GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + .FirstOrDefault(f => f.FieldType == typeof(String)); + + field?.SetValue(logEvent.MessageTemplate, newText); + } + + // Redact in properties + var propertiesToUpdate = new List(); + foreach (var property in logEvent.Properties) + { + if (property.Value is ScalarValue scalarValue && scalarValue.Value is String stringValue && stringValue.Contains(sensitiveValue)) + { + var newValue = stringValue.Replace(sensitiveValue, "*****"); + propertiesToUpdate.Add(new LogEventProperty(property.Key, new ScalarValue(newValue))); + } + } + + foreach (var property in propertiesToUpdate) + { + logEvent.AddOrUpdateProperty(property); + } + } + } +} diff --git a/server/RdtClient.Service/Helpers/FileHelper.cs b/server/RdtClient.Service/Helpers/FileHelper.cs index 08af6ae..6713b7d 100644 --- a/server/RdtClient.Service/Helpers/FileHelper.cs +++ b/server/RdtClient.Service/Helpers/FileHelper.cs @@ -78,7 +78,15 @@ public static class FileHelper public static String RemoveInvalidFileNameChars(String filename) { - return String.Concat(filename.Split(Path.GetInvalidFileNameChars())); + var invalidChars = Path.GetInvalidFileNameChars() + .Concat(['/', '\\']) + .Distinct() + .ToArray(); + + var cleaned = String.Concat(filename.Split(invalidChars)); + cleaned = cleaned.Replace("..", ""); + cleaned = cleaned.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return cleaned; } public static String GetDirectoryContents(String path) diff --git a/server/RdtClient.Service/Helpers/FileSizeHelper.cs b/server/RdtClient.Service/Helpers/FileSizeHelper.cs new file mode 100644 index 0000000..173378f --- /dev/null +++ b/server/RdtClient.Service/Helpers/FileSizeHelper.cs @@ -0,0 +1,24 @@ +namespace RdtClient.Service.Helpers; + +public static class FileSizeHelper +{ + public static String FormatSize(Int64? bytes) + { + if (bytes == null) + { + return "0 B"; + } + + String[] units = { "B", "KB", "MB", "GB", "TB", "PB", "EB" }; + var unitIndex = 0; + Double size = bytes.Value; + + while (size >= 1024 && unitIndex < units.Length - 1) + { + size /= 1024; + unitIndex++; + } + + return $"{size:0.##} {units[unitIndex]}"; + } +} diff --git a/server/RdtClient.Service/Middleware/SabnzbdHandler.cs b/server/RdtClient.Service/Middleware/SabnzbdHandler.cs new file mode 100644 index 0000000..9dd7452 --- /dev/null +++ b/server/RdtClient.Service/Middleware/SabnzbdHandler.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using RdtClient.Data.Enums; +using RdtClient.Service.Services; + +namespace RdtClient.Service.Middleware; + +public class SabnzbdRequirement : IAuthorizationRequirement +{ +} + +public class SabnzbdHandler(Authentication authentication, IHttpContextAccessor httpContextAccessor) : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, SabnzbdRequirement requirement) + { + var httpContext = httpContextAccessor.HttpContext; + + if (context.User.Identity?.IsAuthenticated == true) + { + context.Succeed(requirement); + return; + } + + if (Settings.Get.General.AuthenticationType == AuthenticationType.None) + { + context.Succeed(requirement); + return; + } + + if (httpContext != null) + { + var request = httpContext.Request; + + String? GetParam(String name) + { + var value = request.Query[name].ToString(); + if (String.IsNullOrWhiteSpace(value) && request.HasFormContentType) + { + value = request.Form[name].ToString(); + } + + return value; + } + + var maUsername = GetParam("ma_username"); + var maPassword = GetParam("ma_password"); + + if (!String.IsNullOrWhiteSpace(maUsername) && !String.IsNullOrWhiteSpace(maPassword)) + { + var loginResult = await authentication.Login(maUsername, maPassword); + if (loginResult.Succeeded) + { + context.Succeed(requirement); + } + } + } + } +} diff --git a/server/RdtClient.Service/RdtClient.Service.csproj b/server/RdtClient.Service/RdtClient.Service.csproj index afa389b..063dd75 100644 --- a/server/RdtClient.Service/RdtClient.Service.csproj +++ b/server/RdtClient.Service/RdtClient.Service.csproj @@ -25,7 +25,7 @@ - + diff --git a/server/RdtClient.Service/Services/Authentication.cs b/server/RdtClient.Service/Services/Authentication.cs index bf396b0..d9a51a0 100644 --- a/server/RdtClient.Service/Services/Authentication.cs +++ b/server/RdtClient.Service/Services/Authentication.cs @@ -14,7 +14,7 @@ public class Authentication(SignInManager signInManager, UserManag return result; } - public async Task Login(String userName, String password) + public virtual async Task Login(String userName, String password) { if (String.IsNullOrWhiteSpace(userName) || String.IsNullOrWhiteSpace(password)) { diff --git a/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs b/server/RdtClient.Service/Services/DebridClients/AllDebridDebridClient.cs similarity index 86% rename from server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs rename to server/RdtClient.Service/Services/DebridClients/AllDebridDebridClient.cs index fb2d68d..bb6dedf 100644 --- a/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/AllDebridDebridClient.cs @@ -4,12 +4,12 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; using RdtClient.Service.Helpers; using File = AllDebridNET.File; using Torrent = RdtClient.Data.Models.Data.Torrent; -namespace RdtClient.Service.Services.TorrentClients; +namespace RdtClient.Service.Services.DebridClients; public interface IAllDebridNetClientFactory { @@ -51,14 +51,38 @@ public class AllDebridNetClientFactory(ILogger logger } } -public class AllDebridTorrentClient(ILogger logger, IAllDebridNetClientFactory allDebridNetClientFactory, IDownloadableFileFilter fileFilter) - : ITorrentClient +public class AllDebridDebridClient(ILogger logger, IAllDebridNetClientFactory allDebridNetClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient { private static readonly Int64 SessionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - private static List _cache = []; - private static Int64 _sessionCounter; + private static List _cache = []; + private static Int64 _sessionCounter = 0; - public async Task> GetTorrents() + private static DebridClientTorrent Map(Magnet torrent) + { + var files = GetFiles(torrent.Files); + return new() + { + Id = torrent.Id.ToString(), + Filename = torrent.Filename ?? "", + OriginalFilename = torrent.Filename, + Hash = torrent.Hash ?? "", + Bytes = torrent.Size ?? 0, + OriginalBytes = torrent.Size ?? 0, + Host = null, + Split = 0, + Progress = (Int64)Math.Round((torrent.Downloaded ?? 0) * 100.0 / (torrent.Size ?? 1)), + Status = torrent.Status, + StatusCode = torrent.StatusCode ?? 0, + Added = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(torrent.UploadDate ?? 0), + Files = files, + Links = [], + Ended = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(torrent.CompletionDate ?? 0), + Speed = torrent.DownloadSpeed, + Seeders = torrent.Seeders + }; + } + + public async Task> GetDownloads() { var results = await allDebridNetClientFactory.GetClient().Magnet.StatusLiveAsync(SessionId, _sessionCounter); @@ -89,7 +113,7 @@ public class AllDebridTorrentClient(ILogger logger, IAll return _cache; } - public async Task GetUser() + public async Task GetUser() { var user = await allDebridNetClientFactory.GetClient().User.GetAsync() ?? throw new("Unable to get user"); @@ -100,7 +124,7 @@ public class AllDebridTorrentClient(ILogger logger, IAll }; } - public async Task AddMagnet(String magnetLink) + public async Task AddTorrentMagnet(String magnetLink) { var result = await allDebridNetClientFactory.GetClient().Magnet.UploadMagnetAsync(magnetLink); @@ -114,7 +138,7 @@ public class AllDebridTorrentClient(ILogger logger, IAll return resultId; } - public async Task AddFile(Byte[] bytes) + public async Task AddTorrentFile(Byte[] bytes) { var result = await allDebridNetClientFactory.GetClient().Magnet.UploadFileAsync(bytes); @@ -128,9 +152,19 @@ public class AllDebridTorrentClient(ILogger logger, IAll return resultId; } - public Task> GetAvailableFiles(String hash) + public Task AddNzbLink(String nzbLink) { - return Task.FromResult>([]); + throw new NotSupportedException(); + } + + public Task AddNzbFile(Byte[] bytes, String? name) + { + throw new NotSupportedException(); + } + + public Task> GetAvailableFiles(String hash) + { + return Task.FromResult>([]); } /// @@ -139,12 +173,12 @@ public class AllDebridTorrentClient(ILogger logger, IAll return Task.FromResult(torrent.Files.Count); } - public async Task Delete(String torrentId) + public async Task Delete(Torrent torrent) { - await allDebridNetClientFactory.GetClient().Magnet.DeleteAsync(torrentId); + await allDebridNetClientFactory.GetClient().Magnet.DeleteAsync(torrent.RdId!); } - public async Task Unrestrict(String link) + public async Task Unrestrict(Torrent torrent, String link) { var result = await allDebridNetClientFactory.GetClient().Links.DownloadLinkAsync(link); @@ -156,7 +190,7 @@ public class AllDebridTorrentClient(ILogger logger, IAll return result.Link; } - public async Task UpdateData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent) + public async Task UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent) { try { @@ -255,40 +289,13 @@ public class AllDebridTorrentClient(ILogger logger, IAll return Task.FromResult(download.FileName); } - private static TorrentClientTorrent Map(Magnet torrent) - { - var files = GetFiles(torrent.Files); - - return new() - { - Id = torrent.Id.ToString(), - Filename = torrent.Filename ?? "", - OriginalFilename = torrent.Filename, - Hash = torrent.Hash ?? "", - Bytes = torrent.Size ?? 0, - OriginalBytes = torrent.Size ?? 0, - Host = null, - Split = 0, - Progress = (Int64)Math.Round(((torrent.Downloaded ?? 0) * 100.0) / (torrent.Size ?? 1)), - Status = torrent.Status, - StatusCode = torrent.StatusCode ?? 0, - Added = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(torrent.UploadDate ?? 0), - Files = files, - Links = [], - Ended = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(torrent.CompletionDate ?? 0), - Speed = torrent.DownloadSpeed, - Seeders = torrent.Seeders - }; - } - - private async Task GetInfo(String torrentId) + private async Task GetInfo(String torrentId) { var result = await allDebridNetClientFactory.GetClient().Magnet.StatusAsync(torrentId) ?? throw new($"Unable to find magnet with ID {torrentId}"); return Map(result); } - - private static List GetFiles(List? files, String parentPath = "") + private static List GetFiles(List? files, String parentPath = "") { if (files == null) { @@ -301,7 +308,7 @@ public class AllDebridTorrentClient(ILogger logger, IAll ? file.FolderOrFileName : Path.Combine(parentPath, file.FolderOrFileName); - var result = new List(); + var result = new List(); // If it's a file (has size) if (file.Size.HasValue) diff --git a/server/RdtClient.Service/Services/TorrentClients/DebridLinkTorrentClient.cs b/server/RdtClient.Service/Services/DebridClients/DebridLinkTorrentClient.cs similarity index 86% rename from server/RdtClient.Service/Services/TorrentClients/DebridLinkTorrentClient.cs rename to server/RdtClient.Service/Services/DebridClients/DebridLinkTorrentClient.cs index 064321c..a18ee6a 100644 --- a/server/RdtClient.Service/Services/TorrentClients/DebridLinkTorrentClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/DebridLinkTorrentClient.cs @@ -4,16 +4,88 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; using RdtClient.Service.Helpers; using Download = RdtClient.Data.Models.Data.Download; using Torrent = DebridLinkFrNET.Models.Torrent; -namespace RdtClient.Service.Services.TorrentClients; +namespace RdtClient.Service.Services.DebridClients; -public class DebridLinkClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : ITorrentClient +public class DebridLinkClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient { - public async Task> GetTorrents() + private DebridLinkFrNETClient GetClient() + { + try + { + var apiKey = Settings.Get.Provider.ApiKey; + + if (String.IsNullOrWhiteSpace(apiKey)) + { + throw new("DebridLink API Key not set in the settings"); + } + + var httpClient = httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); + + var debridLinkClient = new DebridLinkFrNETClient(apiKey, httpClient); + + return debridLinkClient; + } + catch (AggregateException ae) + { + foreach (var inner in ae.InnerExceptions) + { + logger.LogError(inner, $"The connection to DebridLink has failed: {inner.Message}"); + } + + throw; + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + logger.LogError(ex, $"The connection to DebridLink has timed out: {ex.Message}"); + + throw; + } + catch (TaskCanceledException ex) + { + logger.LogError(ex, $"The connection to DebridLink has timed out: {ex.Message}"); + + throw; + } + } + + private DebridClientTorrent Map(Torrent torrent) + { + return new() + { + Id = torrent.Id ?? "", + Filename = torrent.Name ?? "", + OriginalFilename = torrent.Name ?? "", + Hash = torrent.HashString ?? "", + Bytes = torrent.TotalSize, + OriginalBytes = 0, + Host = torrent.ServerId ?? "", + Split = 0, + Progress = torrent.DownloadPercent, + Status = torrent.Status.ToString(), + Added = DateTimeOffset.FromUnixTimeSeconds(torrent.Created), + Files = (torrent.Files ?? []).Select((m, i) => new DebridClientFile + { + Path = m.Name ?? "", + Bytes = m.Size, + Id = i, + Selected = true, + DownloadLink = m.DownloadUrl + }) + .ToList(), + Links = torrent.Files?.Select(m => m.DownloadUrl.ToString()).ToList(), + Ended = null, + Speed = torrent.UploadSpeed, + Seeders = torrent.PeersConnected, + }; + } + + public async Task> GetDownloads() { var page = 0; var results = new List(); @@ -35,7 +107,7 @@ public class DebridLinkClient(ILogger logger, IHttpClientFacto return results.Select(Map).ToList(); } - public async Task GetUser() + public async Task GetUser() { var user = await GetClient().Account.Infos(); @@ -46,23 +118,33 @@ public class DebridLinkClient(ILogger logger, IHttpClientFacto }; } - public async Task AddMagnet(String magnetLink) + public async Task AddTorrentMagnet(String magnetLink) { var result = await GetClient().Seedbox.AddTorrentAsync(magnetLink); return result.Id ?? ""; } - public async Task AddFile(Byte[] bytes) + public async Task AddTorrentFile(Byte[] bytes) { var result = await GetClient().Seedbox.AddTorrentByFileAsync(bytes); return result.Id ?? ""; } - public Task> GetAvailableFiles(String hash) + public Task AddNzbLink(String nzbLink) { - return Task.FromResult>([]); + throw new NotSupportedException(); + } + + public Task AddNzbFile(Byte[] bytes, String? name) + { + throw new NotSupportedException(); + } + + public Task> GetAvailableFiles(String hash) + { + return Task.FromResult>([]); } /// @@ -71,17 +153,17 @@ public class DebridLinkClient(ILogger logger, IHttpClientFacto return Task.FromResult(torrent.Files.Count); } - public async Task Delete(String torrentId) + public async Task Delete(Data.Models.Data.Torrent torrent) { - await GetClient().Seedbox.DeleteAsync(torrentId); + await GetClient().Seedbox.DeleteAsync(torrent.RdId!); } - public Task Unrestrict(String link) + public Task Unrestrict(Data.Models.Data.Torrent torrent, String link) { return Task.FromResult(link); } - public async Task UpdateData(Data.Models.Data.Torrent torrent, TorrentClientTorrent? torrentClientTorrent) + public async Task UpdateData(Data.Models.Data.Torrent torrent, DebridClientTorrent? torrentClientTorrent) { try { @@ -197,79 +279,7 @@ public class DebridLinkClient(ILogger logger, IHttpClientFacto return Task.FromResult(download.FileName); } - private DebridLinkFrNETClient GetClient() - { - try - { - var apiKey = Settings.Get.Provider.ApiKey; - - if (String.IsNullOrWhiteSpace(apiKey)) - { - throw new("DebridLink API Key not set in the settings"); - } - - var httpClient = httpClientFactory.CreateClient(); - httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); - - var debridLinkClient = new DebridLinkFrNETClient(apiKey, httpClient); - - return debridLinkClient; - } - catch (AggregateException ae) - { - foreach (var inner in ae.InnerExceptions) - { - logger.LogError(inner, $"The connection to DebridLink has failed: {inner.Message}"); - } - - throw; - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - logger.LogError(ex, $"The connection to DebridLink has timed out: {ex.Message}"); - - throw; - } - catch (TaskCanceledException ex) - { - logger.LogError(ex, $"The connection to DebridLink has timed out: {ex.Message}"); - - throw; - } - } - - private TorrentClientTorrent Map(Torrent torrent) - { - return new() - { - Id = torrent.Id ?? "", - Filename = torrent.Name ?? "", - OriginalFilename = torrent.Name ?? "", - Hash = torrent.HashString ?? "", - Bytes = torrent.TotalSize, - OriginalBytes = 0, - Host = torrent.ServerId ?? "", - Split = 0, - Progress = torrent.DownloadPercent, - Status = torrent.Status.ToString(), - Added = DateTimeOffset.FromUnixTimeSeconds(torrent.Created), - Files = (torrent.Files ?? []).Select((m, i) => new TorrentClientFile - { - Path = m.Name ?? "", - Bytes = m.Size, - Id = i, - Selected = true, - DownloadLink = m.DownloadUrl - }) - .ToList(), - Links = torrent.Files?.Select(m => m.DownloadUrl.ToString()).ToList(), - Ended = null, - Speed = torrent.UploadSpeed, - Seeders = torrent.PeersConnected - }; - } - - private async Task GetInfo(String torrentId) + private async Task GetInfo(String torrentId) { var result = await GetClient().Seedbox.ListAsync(torrentId); diff --git a/server/RdtClient.Service/Services/TorrentClients/ITorrentClient.cs b/server/RdtClient.Service/Services/DebridClients/IDebridClient.cs similarity index 56% rename from server/RdtClient.Service/Services/TorrentClients/ITorrentClient.cs rename to server/RdtClient.Service/Services/DebridClients/IDebridClient.cs index 9f58084..228982d 100644 --- a/server/RdtClient.Service/Services/TorrentClients/ITorrentClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/IDebridClient.cs @@ -1,16 +1,17 @@ using RdtClient.Data.Models.Data; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; -namespace RdtClient.Service.Services.TorrentClients; +namespace RdtClient.Service.Services.DebridClients; -public interface ITorrentClient +public interface IDebridClient { - Task> GetTorrents(); - Task GetUser(); - Task AddMagnet(String magnetLink); - Task AddFile(Byte[] bytes); - Task> GetAvailableFiles(String hash); - + Task> GetDownloads(); + Task GetUser(); + Task AddTorrentMagnet(String magnetLink); + Task AddTorrentFile(Byte[] bytes); + Task AddNzbLink(String nzbLink); + Task AddNzbFile(Byte[] bytes, String? name); + Task> GetAvailableFiles(String hash); /// /// Tell the debrid provider which files to download. /// @@ -20,10 +21,9 @@ public interface ITorrentClient /// The torrent to select files for /// Number of files selected Task SelectFiles(Torrent torrent); - - Task Delete(String torrentId); - Task Unrestrict(String link); - Task UpdateData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent); + Task Delete(Torrent torrent); + Task Unrestrict(Torrent torrent, String link); + Task UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent); Task?> GetDownloadInfos(Torrent torrent); /// diff --git a/server/RdtClient.Service/Services/TorrentClients/PremiumizeTorrentClient.cs b/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs similarity index 87% rename from server/RdtClient.Service/Services/TorrentClients/PremiumizeTorrentClient.cs rename to server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs index f79c36a..a95297d 100644 --- a/server/RdtClient.Service/Services/TorrentClients/PremiumizeTorrentClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs @@ -4,22 +4,82 @@ using Newtonsoft.Json; using PremiumizeNET; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; using RdtClient.Service.Helpers; using Torrent = RdtClient.Data.Models.Data.Torrent; -namespace RdtClient.Service.Services.TorrentClients; +namespace RdtClient.Service.Services.DebridClients; -public class PremiumizeTorrentClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : ITorrentClient +public class PremiumizeDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient { - public async Task> GetTorrents() + private PremiumizeNETClient GetClient() + { + try + { + var apiKey = Settings.Get.Provider.ApiKey; + + if (String.IsNullOrWhiteSpace(apiKey)) + { + throw new("Premiumize API Key not set in the settings"); + } + + var httpClient = httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(10); + + var premiumizeNetClient = new PremiumizeNETClient(apiKey, httpClient); + + return premiumizeNetClient; + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + logger.LogError(ex, $"The connection to Premiumize has timed out: {ex.Message}"); + + throw; + } + catch (TaskCanceledException ex) + { + logger.LogError(ex, $"The connection to Premiumize has timed out: {ex.Message}"); + + throw; + } + } + + private static DebridClientTorrent Map(Transfer transfer) + { + return new() + { + Id = transfer.Id, + Filename = transfer.Name, + OriginalFilename = transfer.Name, + Hash = transfer.Src, + Bytes = 0, + OriginalBytes = 0, + Host = null, + Split = 0, + Progress = (Int64) ((transfer.Progress ?? 1.0) * 100.0), + Status = transfer.Status, + Message = transfer.Message, + StatusCode = 0, + Added = null, + Files = [], + Links = + [ + transfer.FolderId + ], + Ended = null, + Speed = 0, + Seeders = 0 + }; + } + + public async Task> GetDownloads() { var results = await GetClient().Transfers.ListAsync(); return results.Select(Map).ToList(); } - public async Task GetUser() + public async Task GetUser() { var user = await GetClient().Account.InfoAsync() ?? throw new("Unable to get user"); @@ -30,7 +90,7 @@ public class PremiumizeTorrentClient(ILogger logger, IH }; } - public async Task AddMagnet(String magnetLink) + public async Task AddTorrentMagnet(String magnetLink) { var result = await GetClient().Transfers.CreateAsync(magnetLink, ""); @@ -44,7 +104,7 @@ public class PremiumizeTorrentClient(ILogger logger, IH return resultId; } - public async Task AddFile(Byte[] bytes) + public async Task AddTorrentFile(Byte[] bytes) { var result = await GetClient().Transfers.CreateAsync(bytes, ""); @@ -58,9 +118,19 @@ public class PremiumizeTorrentClient(ILogger logger, IH return resultId; } - public Task> GetAvailableFiles(String hash) + public Task AddNzbLink(String nzbLink) { - return Task.FromResult>([]); + throw new NotSupportedException(); + } + + public Task AddNzbFile(Byte[] bytes, String? name) + { + throw new NotSupportedException(); + } + + public Task> GetAvailableFiles(String hash) + { + return Task.FromResult>([]); } /// @@ -71,17 +141,17 @@ public class PremiumizeTorrentClient(ILogger logger, IH return Task.FromResult(1); } - public async Task Delete(String id) + public async Task Delete(Torrent torrent) { - await GetClient().Transfers.DeleteAsync(id); + await GetClient().Transfers.DeleteAsync(torrent.RdId); } - public Task Unrestrict(String link) + public Task Unrestrict(Torrent torrent, String link) { return Task.FromResult(link); } - public async Task UpdateData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent) + public async Task UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent) { try { @@ -194,67 +264,7 @@ public class PremiumizeTorrentClient(ILogger logger, IH return Task.FromResult(download.FileName); } - private PremiumizeNETClient GetClient() - { - try - { - var apiKey = Settings.Get.Provider.ApiKey; - - if (String.IsNullOrWhiteSpace(apiKey)) - { - throw new("Premiumize API Key not set in the settings"); - } - - var httpClient = httpClientFactory.CreateClient(); - httpClient.Timeout = TimeSpan.FromSeconds(10); - - var premiumizeNetClient = new PremiumizeNETClient(apiKey, httpClient); - - return premiumizeNetClient; - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - logger.LogError(ex, $"The connection to Premiumize has timed out: {ex.Message}"); - - throw; - } - catch (TaskCanceledException ex) - { - logger.LogError(ex, $"The connection to Premiumize has timed out: {ex.Message}"); - - throw; - } - } - - private static TorrentClientTorrent Map(Transfer transfer) - { - return new() - { - Id = transfer.Id, - Filename = transfer.Name, - OriginalFilename = transfer.Name, - Hash = transfer.Src, - Bytes = 0, - OriginalBytes = 0, - Host = null, - Split = 0, - Progress = (Int64)((transfer.Progress ?? 1.0) * 100.0), - Status = transfer.Status, - Message = transfer.Message, - StatusCode = 0, - Added = null, - Files = [], - Links = - [ - transfer.FolderId - ], - Ended = null, - Speed = 0, - Seeders = 0 - }; - } - - private async Task GetInfo(String id) + private async Task GetInfo(String id) { var results = await GetClient().Transfers.ListAsync(); var result = results.FirstOrDefault(m => m.Id == id) ?? throw new($"Unable to find transfer with ID {id}"); diff --git a/server/RdtClient.Service/Services/TorrentClients/RealDebridTorrentClient.cs b/server/RdtClient.Service/Services/DebridClients/RealDebridDebridClient.cs similarity index 87% rename from server/RdtClient.Service/Services/TorrentClients/RealDebridTorrentClient.cs rename to server/RdtClient.Service/Services/DebridClients/RealDebridDebridClient.cs index df27d90..5c24607 100644 --- a/server/RdtClient.Service/Services/TorrentClients/RealDebridTorrentClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/RealDebridDebridClient.cs @@ -4,18 +4,96 @@ using Newtonsoft.Json; using RDNET; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; using RdtClient.Service.Helpers; using Download = RdtClient.Data.Models.Data.Download; using Torrent = RDNET.Torrent; -namespace RdtClient.Service.Services.TorrentClients; +namespace RdtClient.Service.Services.DebridClients; -public class RealDebridTorrentClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : ITorrentClient +public class RealDebridDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient { private TimeSpan? _offset; - public async Task> GetTorrents() + private RdNetClient GetClient() + { + try + { + var apiKey = Settings.Get.Provider.ApiKey; + + if (String.IsNullOrWhiteSpace(apiKey)) + { + throw new("Real-Debrid API Key not set in the settings"); + } + + var httpClient = httpClientFactory.CreateClient(DiConfig.RD_CLIENT); + httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); + + var rdtNetClient = new RdNetClient(null, httpClient, 5, Settings.Get.Provider.ApiHostname); + rdtNetClient.UseApiAuthentication(apiKey); + + // Get the server time to fix up the timezones on results + if (_offset == null) + { + var serverTime = rdtNetClient.Api.GetIsoTimeAsync().Result; + _offset = serverTime.Offset; + } + + return rdtNetClient; + } + catch (AggregateException ae) + { + foreach (var inner in ae.InnerExceptions) + { + logger.LogError(inner, $"The connection to RealDebrid has failed: {inner.Message}"); + } + + throw; + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + logger.LogError(ex, $"The connection to RealDebrid has timed out: {ex.Message}"); + + throw; + } + catch (TaskCanceledException ex) + { + logger.LogError(ex, $"The connection to RealDebrid has timed out: {ex.Message}"); + + throw; + } + } + + private DebridClientTorrent Map(Torrent torrent) + { + return new() + { + Id = torrent.Id, + Filename = torrent.Filename, + OriginalFilename = torrent.OriginalFilename, + Hash = torrent.Hash, + Bytes = torrent.Bytes, + OriginalBytes = torrent.OriginalBytes, + Host = torrent.Host, + Split = torrent.Split, + Progress = torrent.Progress, + Status = torrent.Status, + Added = ChangeTimeZone(torrent.Added)!.Value, + Files = (torrent.Files ?? []).Select(m => new DebridClientFile + { + Path = m.Path, + Bytes = m.Bytes, + Id = m.Id, + Selected = m.Selected + }).ToList(), + Links = torrent.Links, + Ended = ChangeTimeZone(torrent.Ended), + Speed = torrent.Speed, + Seeders = torrent.Seeders, + }; + } + + public async Task> GetDownloads() { var offset = 0; var results = new List(); @@ -37,7 +115,7 @@ public class RealDebridTorrentClient(ILogger logger, IH return results.Select(Map).ToList(); } - public async Task GetUser() + public async Task GetUser() { var user = await GetClient().User.GetAsync(); @@ -48,7 +126,7 @@ public class RealDebridTorrentClient(ILogger logger, IH }; } - public async Task AddMagnet(String magnetLink) + public async Task AddTorrentMagnet(String magnetLink) { var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)); @@ -57,7 +135,7 @@ public class RealDebridTorrentClient(ILogger logger, IH return result.Id; } - public async Task AddFile(Byte[] bytes) + public async Task AddTorrentFile(Byte[] bytes) { var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)); @@ -66,15 +144,25 @@ public class RealDebridTorrentClient(ILogger logger, IH return result.Id; } - public Task> GetAvailableFiles(String hash) + public Task AddNzbLink(String nzbLink) { - return Task.FromResult>([]); + throw new NotSupportedException(); + } + + public Task AddNzbFile(Byte[] bytes, String? name) + { + throw new NotSupportedException(); + } + + public Task> GetAvailableFiles(String hash) + { + return Task.FromResult>([]); } /// public async Task SelectFiles(Data.Models.Data.Torrent torrent) { - List files; + List files; Log("Seleting files", torrent); @@ -105,12 +193,12 @@ public class RealDebridTorrentClient(ILogger logger, IH return fileIds.Length; } - public async Task Delete(String torrentId) + public async Task Delete(Data.Models.Data.Torrent torrent) { - await GetClient().Torrents.DeleteAsync(torrentId); + await GetClient().Torrents.DeleteAsync(torrent.RdId!); } - public async Task Unrestrict(String link) + public async Task Unrestrict(Data.Models.Data.Torrent torrent, String link) { var result = await GetClient().Unrestrict.LinkAsync(link); @@ -122,7 +210,7 @@ public class RealDebridTorrentClient(ILogger logger, IH return result.Download; } - public async Task UpdateData(Data.Models.Data.Torrent torrent, TorrentClientTorrent? torrentClientTorrent) + public async Task UpdateData(Data.Models.Data.Torrent torrent, DebridClientTorrent? torrentClientTorrent) { try { @@ -283,84 +371,6 @@ public class RealDebridTorrentClient(ILogger logger, IH return Task.FromResult(HttpUtility.UrlDecode(uri.Segments.Last())); } - private RdNetClient GetClient() - { - try - { - var apiKey = Settings.Get.Provider.ApiKey; - - if (String.IsNullOrWhiteSpace(apiKey)) - { - throw new("Real-Debrid API Key not set in the settings"); - } - - var httpClient = httpClientFactory.CreateClient(DiConfig.RD_CLIENT); - httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); - - var rdtNetClient = new RdNetClient(null, httpClient, 5, Settings.Get.Provider.ApiHostname); - rdtNetClient.UseApiAuthentication(apiKey); - - // Get the server time to fix up the timezones on results - if (_offset == null) - { - var serverTime = rdtNetClient.Api.GetIsoTimeAsync().Result; - _offset = serverTime.Offset; - } - - return rdtNetClient; - } - catch (AggregateException ae) - { - foreach (var inner in ae.InnerExceptions) - { - logger.LogError(inner, $"The connection to RealDebrid has failed: {inner.Message}"); - } - - throw; - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - logger.LogError(ex, $"The connection to RealDebrid has timed out: {ex.Message}"); - - throw; - } - catch (TaskCanceledException ex) - { - logger.LogError(ex, $"The connection to RealDebrid has timed out: {ex.Message}"); - - throw; - } - } - - private TorrentClientTorrent Map(Torrent torrent) - { - return new() - { - Id = torrent.Id, - Filename = torrent.Filename, - OriginalFilename = torrent.OriginalFilename, - Hash = torrent.Hash, - Bytes = torrent.Bytes, - OriginalBytes = torrent.OriginalBytes, - Host = torrent.Host, - Split = torrent.Split, - Progress = torrent.Progress, - Status = torrent.Status, - Added = ChangeTimeZone(torrent.Added)!.Value, - Files = (torrent.Files ?? []).Select(m => new TorrentClientFile - { - Path = m.Path, - Bytes = m.Bytes, - Id = m.Id, - Selected = m.Selected - }) - .ToList(), - Links = torrent.Links, - Ended = ChangeTimeZone(torrent.Ended), - Speed = torrent.Speed, - Seeders = torrent.Seeders - }; - } private DateTimeOffset? ChangeTimeZone(DateTimeOffset? dateTimeOffset) { @@ -372,7 +382,7 @@ public class RealDebridTorrentClient(ILogger logger, IH return dateTimeOffset?.Subtract(_offset.Value).ToOffset(_offset.Value); } - private async Task GetInfo(String torrentId) + private async Task GetInfo(String torrentId) { var result = await GetClient().Torrents.GetInfoAsync(torrentId); diff --git a/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs similarity index 54% rename from server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs rename to server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs index a28d42e..cb23cc6 100644 --- a/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs @@ -3,40 +3,188 @@ using Microsoft.Extensions.Logging; using MonoTorrent; using Newtonsoft.Json; using RdtClient.Data.Enums; +using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.Data; -using RdtClient.Data.Models.TorrentClient; using RdtClient.Service.Helpers; using TorBoxNET; using Torrent = RdtClient.Data.Models.Data.Torrent; -namespace RdtClient.Service.Services.TorrentClients; +namespace RdtClient.Service.Services.DebridClients; -public class TorBoxTorrentClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : ITorrentClient +public class TorBoxDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient { private TimeSpan? _offset; - - public async Task> GetTorrents() + protected virtual ITorBoxNetClient GetClient() { - var torrents = new List(); - - var currentTorrents = await GetClient().Torrents.GetCurrentAsync(true); - - if (currentTorrents != null) + try { - torrents.AddRange(currentTorrents); + var apiKey = Settings.Get.Provider.ApiKey; + + if (String.IsNullOrWhiteSpace(apiKey)) + { + throw new("TorBox API Key not set in the settings"); + } + + var httpClient = httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); + + var torBoxNetClient = new TorBoxNetClient(null, httpClient, 5); + torBoxNetClient.UseApiAuthentication(apiKey); + + // Get the server time to fix up the timezones on results + if (_offset == null) + { + var serverTime = DateTimeOffset.UtcNow; + _offset = serverTime.Offset; + } + + return torBoxNetClient; } - - var queuedTorrents = await GetClient().Torrents.GetQueuedAsync(true); - - if (queuedTorrents != null) + catch (AggregateException ae) { - torrents.AddRange(queuedTorrents); - } + foreach (var inner in ae.InnerExceptions) + { + logger.LogError(inner, $"The connection to TorBox has failed: {inner.Message}"); + } - return torrents.Select(Map).ToList(); + throw; + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); + + throw; + } + catch (TaskCanceledException ex) + { + logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); + + throw; + } } - public async Task GetUser() + protected virtual async Task?> GetCurrentTorrents() + { + return await GetClient().Torrents.GetCurrentAsync(true); + } + + protected virtual async Task?> GetQueuedTorrents() + { + return await GetClient().Torrents.GetQueuedAsync(true); + } + + protected virtual async Task?> GetCurrentUsenet() + { + return await GetClient().Usenet.GetCurrentAsync(true); + } + + protected virtual async Task?> GetQueuedUsenet() + { + return await GetClient().Usenet.GetQueuedAsync(true); + } + + protected virtual async Task>> GetTorrentAvailability(String hash) + { + return await GetClient().Torrents.GetAvailabilityAsync(hash, listFiles: true); + } + + protected virtual async Task>> GetUsenetAvailability(String hash) + { + return await GetClient().Usenet.GetAvailabilityAsync(hash, listFiles: true); + } + + private DebridClientTorrent Map(TorrentInfoResult torrent) + { + return new() + { + Id = torrent.Hash, + Filename = torrent.Name, + OriginalFilename = torrent.Name, + Hash = torrent.Hash, + Bytes = torrent.Size, + OriginalBytes = torrent.Size, + Host = torrent.DownloadPresent.ToString(), + Split = 0, + Progress = (Int64)(torrent.Progress * 100.0), + Status = torrent.DownloadState, + Type = DownloadType.Torrent, + Added = ChangeTimeZone(torrent.CreatedAt)!.Value, + Files = (torrent.Files ?? []).Select(m => new DebridClientFile + { + Path = String.Join("/", m.Name.Split('/').Skip(1)), + Bytes = m.Size, + Id = m.Id, + Selected = true + }).ToList(), + Links = [], + Ended = ChangeTimeZone(torrent.UpdatedAt), + Speed = torrent.DownloadSpeed, + Seeders = torrent.Seeds, + }; + } + + private DebridClientTorrent Map(UsenetInfoResult usenet) + { + return new() + { + Id = usenet.Hash, + Filename = usenet.Name, + OriginalFilename = usenet.Name, + Hash = usenet.Hash, + Bytes = usenet.Size, + OriginalBytes = usenet.Size, + Host = usenet.DownloadPresent.ToString(), + Split = 0, + Progress = (Int64)(usenet.Progress * 100.0), + Status = usenet.DownloadState, + Type = DownloadType.Nzb, + Added = ChangeTimeZone(usenet.CreatedAt)!.Value, + Files = (usenet.Files ?? []).Select(m => new DebridClientFile + { + Path = String.Join("/", m.Name.Split('/').Skip(1)), + Bytes = m.Size, + Id = m.Id, + Selected = true + }).ToList(), + Links = [], + Ended = ChangeTimeZone(usenet.UpdatedAt), + Speed = usenet.DownloadSpeed, + Seeders = 0, + }; + } + + public async Task> GetDownloads() + { + var results = new List(); + + var currentTorrents = await GetCurrentTorrents(); + if (currentTorrents != null) + { + results.AddRange(currentTorrents.Select(Map)); + } + + var queuedTorrents = await GetQueuedTorrents(); + if (queuedTorrents != null) + { + results.AddRange(queuedTorrents.Select(Map)); + } + + var currentNzbs = await GetCurrentUsenet(); + if (currentNzbs != null) + { + results.AddRange(currentNzbs.Select(Map)); + } + + var queuedNzbs = await GetQueuedUsenet(); + if (queuedNzbs != null) + { + results.AddRange(queuedNzbs.Select(Map)); + } + + return results; + } + + public async Task GetUser() { var user = await GetClient().User.GetAsync(false); @@ -47,7 +195,7 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien }; } - public async Task AddMagnet(String magnetLink) + public async Task AddTorrentMagnet(String magnetLink) { var user = await GetClient().User.GetAsync(true); @@ -63,7 +211,7 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien return result.Data!.Hash!; } - public async Task AddFile(Byte[] bytes) + public async Task AddTorrentFile(Byte[] bytes) { var user = await GetClient().User.GetAsync(true); @@ -81,18 +229,42 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien return result.Data!.Hash!; } - public async Task> GetAvailableFiles(String hash) + public async Task AddNzbLink(String nzbLink) { - var availability = await GetClient().Torrents.GetAvailabilityAsync(hash, true); + var result = await GetClient().Usenet.AddLinkAsync(nzbLink); + + return result.Data!.Hash!; + } + + public virtual async Task AddNzbFile(Byte[] bytes, String? name) + { + var result = await GetClient().Usenet.AddFileAsync(bytes, name: name); + + return result.Data!.Hash!; + } + + public async Task> GetAvailableFiles(String hash) + { + var availability = await GetTorrentAvailability(hash); if (availability.Data != null && availability.Data.Count > 0) { - return (availability.Data[0]?.Files ?? []).Select(file => new TorrentClientAvailableFile - { - Filename = file.Name, - Filesize = file.Size - }) - .ToList(); + return (availability.Data[0]?.Files ?? []).Select(file => new DebridClientAvailableFile + { + Filename = file.Name, + Filesize = file.Size + }).ToList(); + } + + var usenetAvailability = await GetUsenetAvailability(hash); + + if (usenetAvailability.Data != null && usenetAvailability.Data.Count > 0) + { + return (usenetAvailability.Data[0]?.Files ?? []).Select(file => new DebridClientAvailableFile + { + Filename = file.Name, + Filesize = file.Size + }).ToList(); } return []; @@ -104,29 +276,69 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien return Task.FromResult(torrent.Files.Count); } - public async Task Delete(String torrentId) + public async Task Delete(Torrent torrent) { - await GetClient().Torrents.ControlAsync(torrentId, "delete"); + if (torrent.RdId == null) + { + return; + } + + if (torrent.Type == DownloadType.Nzb) + { + await GetClient().Usenet.ControlAsync(torrent.RdId, "delete"); + } + else + { + await GetClient().Torrents.ControlAsync(torrent.RdId, "delete"); + } } - public async Task Unrestrict(String link) + public async Task Unrestrict(Torrent torrent, String link) { + if (String.IsNullOrWhiteSpace(link)) + { + throw new ArgumentException("Link cannot be null or empty", nameof(link)); + } + var segments = link.Split('/'); + if (segments is not [_, _, _, _, var torrentIdStr, var fileIdStrOrZip]) + { + throw new ArgumentException($"Invalid link format: {link}", nameof(link)); + } - var zipped = segments[5] == "zip"; - var fileId = zipped ? "0" : segments[5]; + var zipped = fileIdStrOrZip == "zip"; + var fileIdStr = zipped ? "0" : fileIdStrOrZip; - var result = await GetClient().Torrents.RequestDownloadAsync(Convert.ToInt32(segments[4]), Convert.ToInt32(fileId), zipped); + if (!Int32.TryParse(torrentIdStr, out var torrentId)) + { + throw new ArgumentException($"Invalid torrent ID in link segment 4: {torrentIdStr}", nameof(link)); + } + + if (!Int32.TryParse(fileIdStr, out var fileId)) + { + throw new ArgumentException($"Invalid file ID in link segment 5: {fileId}", nameof(link)); + } + + Response result; + + if (torrent.Type == DownloadType.Nzb) + { + result = await GetClient().Usenet.RequestDownloadAsync(torrentId, fileId, zipped); + } + else + { + result = await GetClient().Torrents.RequestDownloadAsync(torrentId, fileId, zipped); + } if (result.Error != null) { - throw new("Unrestrict returned an invalid download"); + throw new($"Unrestrict returned an invalid download: {result.Error}"); } return result.Data!; } - public async Task UpdateData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent) + public async Task UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent) { try { @@ -135,7 +347,7 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien return torrent; } - var rdTorrent = torrentClientTorrent ?? await GetInfo(torrent.Hash) ?? throw new($"Resource not found"); + var rdTorrent = torrentClientTorrent ?? await GetInfo(torrent.RdId, torrent.Type) ?? throw new($"Resource not found"); if (!String.IsNullOrWhiteSpace(rdTorrent.Filename)) { @@ -214,7 +426,29 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien public async Task?> GetDownloadInfos(Torrent torrent) { - var torrentId = await GetClient().Torrents.GetHashInfoAsync(torrent.Hash, true); + Int32? id; + + if (torrent.Type == DownloadType.Nzb) + { + if (torrent.RdId == null) + { + return null; + } + + var usenets = await GetClient().Usenet.GetCurrentAsync(true); + var usenet = usenets?.FirstOrDefault(m => m.Hash == torrent.RdId); + id = (Int32?)usenet?.Id; + } + else + { + var torrentId = await GetClient().Torrents.GetHashInfoAsync(torrent.Hash, skipCache: true); + id = torrentId?.Id; + } + + if (id == null) + { + return null; + } var downloadableFiles = torrent.Files.Where(file => fileFilter.IsDownloadable(torrent, file.Path, file.Bytes)).ToList(); if (downloadableFiles.Count == torrent.Files.Count && torrent.DownloadClient != Data.Enums.DownloadClient.Symlink && Settings.Get.Provider.PreferZippedDownloads) @@ -225,7 +459,7 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien [ new() { - RestrictedLink = $"https://torbox.app/fakedl/{torrentId?.Id}/zip", + RestrictedLink = $"https://torbox.app/fakedl/{id}/zip", FileName = $"{torrent.RdName}.zip" } ]; @@ -235,7 +469,7 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien return downloadableFiles.Select(file => new DownloadInfo { - RestrictedLink = $"https://torbox.app/fakedl/{torrentId?.Id}/{file.Id}", + RestrictedLink = $"https://torbox.app/fakedl/{id}/{file.Id}", FileName = Path.GetFileName(file.Path) }) .ToList(); @@ -250,84 +484,6 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien return Task.FromResult(download.FileName); } - private TorBoxNetClient GetClient() - { - try - { - var apiKey = Settings.Get.Provider.ApiKey; - - if (String.IsNullOrWhiteSpace(apiKey)) - { - throw new("TorBox API Key not set in the settings"); - } - - var httpClient = httpClientFactory.CreateClient(); - httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); - - var torBoxNetClient = new TorBoxNetClient(null, httpClient, 5); - torBoxNetClient.UseApiAuthentication(apiKey); - - // Get the server time to fix up the timezones on results - if (_offset == null) - { - var serverTime = DateTimeOffset.UtcNow; - _offset = serverTime.Offset; - } - - return torBoxNetClient; - } - catch (AggregateException ae) - { - foreach (var inner in ae.InnerExceptions) - { - logger.LogError(inner, $"The connection to TorBox has failed: {inner.Message}"); - } - - throw; - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); - - throw; - } - catch (TaskCanceledException ex) - { - logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); - - throw; - } - } - - private TorrentClientTorrent Map(TorrentInfoResult torrent) - { - return new() - { - Id = torrent.Hash, - Filename = torrent.Name, - OriginalFilename = torrent.Name, - Hash = torrent.Hash, - Bytes = torrent.Size, - OriginalBytes = torrent.Size, - Host = torrent.DownloadPresent.ToString(), - Split = 0, - Progress = (Int64)(torrent.Progress * 100.0), - Status = torrent.DownloadState, - Added = ChangeTimeZone(torrent.CreatedAt)!.Value, - Files = (torrent.Files ?? []).Select(m => new TorrentClientFile - { - Path = String.Join("/", m.Name.Split('/').Skip(1)), - Bytes = m.Size, - Id = m.Id, - Selected = true - }) - .ToList(), - Links = [], - Ended = ChangeTimeZone(torrent.UpdatedAt), - Speed = torrent.DownloadSpeed, - Seeders = torrent.Seeds - }; - } private DateTimeOffset? ChangeTimeZone(DateTimeOffset? dateTimeOffset) { @@ -339,24 +495,39 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien return dateTimeOffset?.Subtract(_offset.Value).ToOffset(_offset.Value); } - private async Task GetInfo(String torrentHash) + private async Task GetInfo(String id, DownloadType type) { - var result = await GetClient().Torrents.GetHashInfoAsync(torrentHash, true); + if (type == DownloadType.Nzb) + { + var usenet = await GetClient().Usenet.GetHashInfoAsync(id, skipCache: true); + if (usenet != null) + { + return Map(usenet); + } + } + else + { + var result = await GetClient().Torrents.GetHashInfoAsync(id, skipCache: true); - return Map(result!); + if (result != null) + { + return Map(result); + } + } + + return null; } - public static void MoveHashDirContents(String extractPath, Torrent _torrent) + public static void MoveHashDirContents(String extractPath, Torrent torrent) { - var hashDir = Path.Combine(extractPath, _torrent.Hash); + var hashDir = Path.Combine(extractPath, torrent.Hash); if (Directory.Exists(hashDir)) { var innerFolder = Directory.GetDirectories(hashDir)[0]; var moveDir = extractPath; - - if (!extractPath.EndsWith(_torrent.RdName!)) + if (!extractPath.EndsWith(torrent.RdName!)) { moveDir = hashDir; } @@ -373,7 +544,7 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien Directory.Move(dir, destDir); } - if (!extractPath.Contains(_torrent.RdName!)) + if (!extractPath.Contains(torrent.RdName!)) { Directory.Delete(innerFolder, true); } diff --git a/server/RdtClient.Service/Services/DownloadClient.cs b/server/RdtClient.Service/Services/DownloadClient.cs index 5d84ec8..6b68c70 100644 --- a/server/RdtClient.Service/Services/DownloadClient.cs +++ b/server/RdtClient.Service/Services/DownloadClient.cs @@ -2,7 +2,7 @@ using RdtClient.Data.Models.Data; using RdtClient.Service.Helpers; using RdtClient.Service.Services.Downloaders; -using RdtClient.Service.Services.TorrentClients; +using RdtClient.Service.Services.DebridClients; namespace RdtClient.Service.Services; @@ -45,7 +45,7 @@ public class DownloadClient(Download download, Torrent torrent, String destinati if (torrent.ClientKind == Provider.AllDebrid && Type == Data.Enums.DownloadClient.Symlink) { - downloadPath = AllDebridTorrentClient.GetSymlinkPath(torrent, download); + downloadPath = AllDebridDebridClient.GetSymlinkPath(torrent, download); } if (torrent.ClientKind == Provider.DebridLink && Type == Data.Enums.DownloadClient.Symlink) diff --git a/server/RdtClient.Service/Services/QBittorrent.cs b/server/RdtClient.Service/Services/QBittorrent.cs index 580784b..f65a013 100644 --- a/server/RdtClient.Service/Services/QBittorrent.cs +++ b/server/RdtClient.Service/Services/QBittorrent.cs @@ -19,7 +19,6 @@ public class QBittorrent(ILogger logger, Settings settings, Authent public async Task AuthLogout() { logger.LogDebug("Auth logout"); - await authentication.Logout(); } @@ -191,6 +190,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent var results = new List(); var allTorrents = await torrents.Get(); + allTorrents = allTorrents.Where(m => m.Type == DownloadType.Torrent).ToList(); var prio = 0; @@ -321,7 +321,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent var torrent = await torrents.GetByHash(hash); - if (torrent == null) + if (torrent == null || torrent.Type != DownloadType.Torrent) { return null; } @@ -345,7 +345,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent var torrent = await torrents.GetByHash(hash); - if (torrent == null) + if (torrent == null || torrent.Type != DownloadType.Torrent) { return null; } @@ -419,7 +419,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent var torrent = await torrents.GetByHash(hash); - if (torrent == null) + if (torrent == null || torrent.Type != DownloadType.Torrent) { return; } @@ -511,7 +511,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent { var allTorrents = await torrents.Get(); - var torrentsToGroup = allTorrents.Where(m => !String.IsNullOrWhiteSpace(m.Category)) + var torrentsToGroup = allTorrents.Where(m => m.Type == DownloadType.Torrent && !String.IsNullOrWhiteSpace(m.Category)) .Select(m => m.Category!.ToLower()) .ToList(); @@ -592,6 +592,13 @@ public class QBittorrent(ILogger logger, Settings settings, Authent public async Task TorrentsTopPrio(String hash) { + var torrent = await torrents.GetByHash(hash); + + if (torrent == null || torrent.Type != DownloadType.Torrent) + { + return; + } + await torrents.UpdatePriority(hash, 1); } @@ -599,7 +606,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent { var torrent = await torrents.GetByHash(hash); - if (torrent == null) + if (torrent == null || torrent.Type != DownloadType.Torrent) { return; } @@ -619,7 +626,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent { var torrent = await torrents.GetByHash(hash); - if (torrent == null) + if (torrent == null || torrent.Type != DownloadType.Torrent) { return; } diff --git a/server/RdtClient.Service/Services/RemoteService.cs b/server/RdtClient.Service/Services/RemoteService.cs index d778c85..a985c9b 100644 --- a/server/RdtClient.Service/Services/RemoteService.cs +++ b/server/RdtClient.Service/Services/RemoteService.cs @@ -10,67 +10,65 @@ public class RemoteService(IHubContext hub, Torrents torrents) var allTorrents = await torrents.Get(); var torrentDtos = allTorrents.Select(torrent => new TorrentDto - { - TorrentId = torrent.TorrentId, - Hash = torrent.Hash, - Category = torrent.Category, - DownloadAction = torrent.DownloadAction, - FinishedAction = torrent.FinishedAction, - FinishedActionDelay = torrent.FinishedActionDelay, - HostDownloadAction = torrent.HostDownloadAction, - DownloadMinSize = torrent.DownloadMinSize, - IncludeRegex = torrent.IncludeRegex, - ExcludeRegex = torrent.ExcludeRegex, - DownloadManualFiles = torrent.DownloadManualFiles, - DownloadClient = torrent.DownloadClient, - Added = torrent.Added, - FilesSelected = torrent.FilesSelected, - Completed = torrent.Completed, - IsFile = torrent.IsFile, - Priority = torrent.Priority, - RetryCount = torrent.RetryCount, - DownloadRetryAttempts = torrent.DownloadRetryAttempts, - TorrentRetryAttempts = torrent.TorrentRetryAttempts, - DeleteOnError = torrent.DeleteOnError, - Lifetime = torrent.Lifetime, - Error = torrent.Error, - RdId = torrent.RdId, - RdName = torrent.RdName, - RdSize = torrent.RdSize, - RdHost = torrent.RdHost, - RdSplit = torrent.RdSplit, - RdProgress = torrent.RdProgress, - RdStatus = torrent.RdStatus, - RdStatusRaw = torrent.RdStatusRaw, - RdAdded = torrent.RdAdded, - RdEnded = torrent.RdEnded, - RdSpeed = torrent.RdSpeed, - RdSeeders = torrent.RdSeeders, - Files = torrent.Files, - Downloads = torrent.Downloads.Select(download => new DownloadDto - { - DownloadId = download.DownloadId, - TorrentId = download.TorrentId, - Path = download.Path, - Link = download.Link, - Added = download.Added, - DownloadQueued = download.DownloadQueued, - DownloadStarted = download.DownloadStarted, - DownloadFinished = download.DownloadFinished, - UnpackingQueued = download.UnpackingQueued, - UnpackingStarted = download.UnpackingStarted, - UnpackingFinished = download.UnpackingFinished, - Completed = download.Completed, - RetryCount = download.RetryCount, - Error = download.Error, - BytesTotal = download.BytesTotal, - BytesDone = download.BytesDone, - Speed = download.Speed - }) - .ToList() - }) - .ToList(); - + { + TorrentId = torrent.TorrentId, + Hash = torrent.Hash, + Category = torrent.Category, + DownloadAction = torrent.DownloadAction, + FinishedAction = torrent.FinishedAction, + FinishedActionDelay = torrent.FinishedActionDelay, + HostDownloadAction = torrent.HostDownloadAction, + DownloadMinSize = torrent.DownloadMinSize, + IncludeRegex = torrent.IncludeRegex, + ExcludeRegex = torrent.ExcludeRegex, + DownloadManualFiles = torrent.DownloadManualFiles, + DownloadClient = torrent.DownloadClient, + Added = torrent.Added, + FilesSelected = torrent.FilesSelected, + Completed = torrent.Completed, + Type = torrent.Type, + IsFile = torrent.IsFile, + Priority = torrent.Priority, + RetryCount = torrent.RetryCount, + DownloadRetryAttempts = torrent.DownloadRetryAttempts, + TorrentRetryAttempts = torrent.TorrentRetryAttempts, + DeleteOnError = torrent.DeleteOnError, + Lifetime = torrent.Lifetime, + Error = torrent.Error, + RdId = torrent.RdId, + RdName = torrent.RdName, + RdSize = torrent.RdSize, + RdHost = torrent.RdHost, + RdSplit = torrent.RdSplit, + RdProgress = torrent.RdProgress, + RdStatus = torrent.RdStatus, + RdStatusRaw = torrent.RdStatusRaw, + RdAdded = torrent.RdAdded, + RdEnded = torrent.RdEnded, + RdSpeed = torrent.RdSpeed, + RdSeeders = torrent.RdSeeders, + Files = torrent.Files, + Downloads = torrent.Downloads.Select(download => new DownloadDto + { + DownloadId = download.DownloadId, + TorrentId = download.TorrentId, + Path = download.Path, + Link = download.Link, + Added = download.Added, + DownloadQueued = download.DownloadQueued, + DownloadStarted = download.DownloadStarted, + DownloadFinished = download.DownloadFinished, + UnpackingQueued = download.UnpackingQueued, + UnpackingStarted = download.UnpackingStarted, + UnpackingFinished = download.UnpackingFinished, + Completed = download.Completed, + RetryCount = download.RetryCount, + Error = download.Error, + BytesTotal = download.BytesTotal, + BytesDone = download.BytesDone, + Speed = download.Speed + }).ToList() + }).ToList(); await hub.Clients.All.SendCoreAsync("update", [ torrentDtos diff --git a/server/RdtClient.Service/Services/Sabnzbd.cs b/server/RdtClient.Service/Services/Sabnzbd.cs new file mode 100644 index 0000000..16a3b5c --- /dev/null +++ b/server/RdtClient.Service/Services/Sabnzbd.cs @@ -0,0 +1,220 @@ +using Microsoft.Extensions.Logging; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; +using RdtClient.Data.Models.Internal; +using RdtClient.Data.Models.Sabnzbd; +using RdtClient.Service.Helpers; + +namespace RdtClient.Service.Services; + +public class Sabnzbd(ILogger logger, Torrents torrents, AppSettings appSettings) +{ + public virtual async Task GetQueue() + { + var allTorrents = await torrents.Get(); + var activeTorrents = allTorrents.Where(t => t.Type == DownloadType.Nzb && t.Completed == null).ToList(); + + var queue = new SabnzbdQueue + { + NoOfSlots = activeTorrents.Count, + Slots = activeTorrents.Select((t, index) => + { + Double downloadProgress = 0; + var rdProgress = Math.Clamp(t.RdProgress ?? 0.0, 0.0, 100.0) / 100.0; + + if (t.Downloads is { Count: > 0 }) + { + var bytesDone = t.Downloads.Sum(m => m.BytesDone); + var bytesTotal = t.Downloads.Sum(m => m.BytesTotal); + downloadProgress = bytesTotal > 0 ? Math.Clamp((Double)bytesDone / bytesTotal, 0.0, 1.0) : 0; + } + + var progress = (rdProgress + downloadProgress) / 2.0; + + var timeLeft = "0:00:00"; + var startTime = t.Retry > t.Added ? t.Retry.Value : t.Added; + var elapsed = DateTimeOffset.UtcNow - startTime; + + if (progress is > 0 and < 1.0) + { + var totalEstimatedTime = TimeSpan.FromTicks((Int64)(elapsed.Ticks / progress)); + var remaining = totalEstimatedTime - elapsed; + if (remaining.TotalSeconds > 0) + { + timeLeft = $"{(Int32)remaining.TotalHours}:{remaining.Minutes:D2}:{remaining.Seconds:D2}"; + } + } + + return new SabnzbdQueueSlot + { + Index = index, + NzoId = t.Hash, + Filename = t.RdName ?? t.Hash, + Size = FileSizeHelper.FormatSize(t.Downloads.Sum(d => d.BytesTotal)), + SizeLeft = FileSizeHelper.FormatSize(t.Downloads.Sum(d => d.BytesTotal - d.BytesDone)), + Percentage = (progress * 100.0).ToString("0"), + + Status = t.RdStatus switch + { + TorrentStatus.Processing => "Propagating", + TorrentStatus.Finished => "Completed", + TorrentStatus.Downloading => "Downloading", + TorrentStatus.WaitingForFileSelection => "Propagating", + TorrentStatus.Error => "Failed", + TorrentStatus.Queued => "Queued", + _ => "Downloading" + }, + Category = t.Category ?? "*", + Priority = "Normal", + TimeLeft = timeLeft + }; + }).ToList() + }; + + return queue; + } + + public virtual async Task GetHistory() + { + var allTorrents = await torrents.Get(); + var completedTorrents = allTorrents.Where(t => t.Type == DownloadType.Nzb && t.Completed != null).ToList(); + + var savePath = Settings.AppDefaultSavePath; + + var history = new SabnzbdHistory + { + NoOfSlots = completedTorrents.Count, + TotalSlots = completedTorrents.Count, + Slots = completedTorrents.Select(t => + { + var path = savePath; + + if (!String.IsNullOrWhiteSpace(t.Category)) + { + path = Path.Combine(path, t.Category); + } + + if (!String.IsNullOrWhiteSpace(t.RdName)) + { + path = Path.Combine(path, t.RdName); + } + + return new SabnzbdHistorySlot + { + NzoId = t.Hash, + Name = t.RdName ?? t.Hash, + Size = FileSizeHelper.FormatSize(t.Downloads.Sum(d => d.BytesTotal)), + Status = String.IsNullOrWhiteSpace(t.Error) ? "Completed" : "Failed", + Category = t.Category ?? "Default", + Path = path + }; + }).ToList() + }; + + return history; + } + + public virtual async Task AddFile(Byte[] fileBytes, String? fileName, String? category, Int32? priority) + { + logger.LogDebug($"Add file {category}"); + + var torrent = new Torrent + { + Category = category, + DownloadClient = Settings.Get.DownloadClient.Client, + HostDownloadAction = Settings.Get.Integrations.Default.HostDownloadAction, + FinishedActionDelay = Settings.Get.Integrations.Default.FinishedActionDelay, + DownloadAction = Settings.Get.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll, + FinishedAction = TorrentFinishedAction.None, + DownloadMinSize = Settings.Get.Integrations.Default.MinFileSize, + IncludeRegex = Settings.Get.Integrations.Default.IncludeRegex, + ExcludeRegex = Settings.Get.Integrations.Default.ExcludeRegex, + TorrentRetryAttempts = Settings.Get.Integrations.Default.TorrentRetryAttempts, + DownloadRetryAttempts = Settings.Get.Integrations.Default.DownloadRetryAttempts, + DeleteOnError = Settings.Get.Integrations.Default.DeleteOnError, + Lifetime = Settings.Get.Integrations.Default.TorrentLifetime, + Priority = (priority ?? Settings.Get.Integrations.Default.Priority) > 0 ? 1 : null + }; + + var result = await torrents.AddNzbFileToDebridQueue(fileBytes, fileName, torrent); + return result.Hash; + } + + public virtual async Task AddUrl(String url, String? category, Int32? priority) + { + logger.LogDebug($"Add url {category}"); + + var torrent = new Torrent + { + Category = category, + DownloadClient = Settings.Get.DownloadClient.Client, + HostDownloadAction = Settings.Get.Integrations.Default.HostDownloadAction, + FinishedActionDelay = Settings.Get.Integrations.Default.FinishedActionDelay, + DownloadAction = Settings.Get.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll, + FinishedAction = TorrentFinishedAction.None, + DownloadMinSize = Settings.Get.Integrations.Default.MinFileSize, + IncludeRegex = Settings.Get.Integrations.Default.IncludeRegex, + ExcludeRegex = Settings.Get.Integrations.Default.ExcludeRegex, + TorrentRetryAttempts = Settings.Get.Integrations.Default.TorrentRetryAttempts, + DownloadRetryAttempts = Settings.Get.Integrations.Default.DownloadRetryAttempts, + DeleteOnError = Settings.Get.Integrations.Default.DeleteOnError, + Lifetime = Settings.Get.Integrations.Default.TorrentLifetime, + Priority = priority ?? (Settings.Get.Integrations.Default.Priority > 0 ? Settings.Get.Integrations.Default.Priority : null) + }; + + var result = await torrents.AddNzbLinkToDebridQueue(url, torrent); + return result.Hash; + } + + public virtual async Task Delete(String hash) + { + var torrent = await torrents.GetByHash(hash); + + if (torrent != null) + { + await torrents.Delete(torrent.TorrentId, true, true, true); + } + } + + public virtual List GetCategories() + { + var categoryList = (Settings.Get.General.Categories ?? "") + .Split(",", StringSplitOptions.RemoveEmptyEntries) + .Select(m => m.Trim()) + .Where(m => m != "*") + .Distinct(StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + categoryList.Insert(0, "*"); + + return categoryList; + } + + public virtual SabnzbdConfig GetConfig() + { + var savePath = Settings.AppDefaultSavePath; + + var categoryList = GetCategories(); + + var categories = categoryList.Select((c, i) => new SabnzbdCategory + { + Name = c, + Order = i, + Dir = c == "*" ? "" : Path.Combine(savePath, c) + }).ToList(); + + var config = new SabnzbdConfig + { + Misc = new SabnzbdMisc + { + CompleteDir = savePath, + DownloadDir = savePath, + Port = appSettings.Port.ToString(), + Version = "4.4.0" + }, + Categories = categories + }; + + return config; + } +} diff --git a/server/RdtClient.Service/Services/Torrents.cs b/server/RdtClient.Service/Services/Torrents.cs index 66dd092..1e6f784 100644 --- a/server/RdtClient.Service/Services/Torrents.cs +++ b/server/RdtClient.Service/Services/Torrents.cs @@ -3,16 +3,18 @@ using System.IO.Abstractions; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Xml; +using System.Xml.Linq; using Microsoft.Extensions.Logging; using MonoTorrent; using RdtClient.Data.Data; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Internal; -using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.DebridClient; using RdtClient.Service.BackgroundServices; using RdtClient.Service.Helpers; -using RdtClient.Service.Services.TorrentClients; +using RdtClient.Service.Services.DebridClients; using RdtClient.Service.Wrappers; using Torrent = RdtClient.Data.Models.Data.Torrent; @@ -25,11 +27,11 @@ public class Torrents( IProcessFactory processFactory, IFileSystem fileSystem, IEnricher enricher, - AllDebridTorrentClient allDebridTorrentClient, - PremiumizeTorrentClient premiumizeTorrentClient, - RealDebridTorrentClient realDebridTorrentClient, + AllDebridDebridClient allDebridDebridClient, + PremiumizeDebridClient premiumizeDebridClient, + RealDebridDebridClient realDebridDebridClient, DebridLinkClient debridLinkClient, - TorBoxTorrentClient torBoxTorrentClient) + TorBoxDebridClient torBoxDebridClient) { private static readonly SemaphoreSlim RealDebridUpdateLock = new(1, 1); @@ -38,25 +40,25 @@ public class Torrents( ReferenceHandler = ReferenceHandler.IgnoreCycles }; - private static readonly SemaphoreSlim TorrentResetLock = new(1, 1); - - private ITorrentClient TorrentClient + private IDebridClient DebridClient { get { return Settings.Get.Provider.Provider switch { - Provider.Premiumize => premiumizeTorrentClient, - Provider.RealDebrid => realDebridTorrentClient, - Provider.AllDebrid => allDebridTorrentClient, + Provider.Premiumize => premiumizeDebridClient, + Provider.RealDebrid => realDebridDebridClient, + Provider.AllDebrid => allDebridDebridClient, Provider.DebridLink => debridLinkClient, - Provider.TorBox => torBoxTorrentClient, + Provider.TorBox => torBoxDebridClient, _ => throw new("Invalid Provider") }; } } - public async Task> Get() + private static readonly SemaphoreSlim TorrentResetLock = new(1, 1); + + public virtual async Task> Get() { var torrents = await torrentData.Get(); @@ -82,7 +84,7 @@ public class Torrents( return torrents; } - public async Task GetByHash(String hash) + public virtual async Task GetByHash(String hash) { var torrent = await torrentData.GetByHash(hash); @@ -108,7 +110,83 @@ public class Torrents( await torrentData.UpdateCategory(torrent.TorrentId, category); } - public async Task AddMagnetToDebridQueue(String magnetLink, Torrent torrent) + public virtual async Task AddNzbLinkToDebridQueue(String nzbLink, Torrent torrent) + { + torrent.RdStatus = TorrentStatus.Queued; + try + { + var uri = new Uri(nzbLink); + var lastSegment = uri.Segments.LastOrDefault()?.TrimEnd('/'); + torrent.RdName = !String.IsNullOrWhiteSpace(lastSegment) ? lastSegment : "Unknown NZB"; + } + catch(Exception ex) + { + logger.LogError(ex, "{ex.Message}, trying to parse {nzbLink}", ex.Message, nzbLink); + throw new ($"{ex.Message}, trying to parse {nzbLink}"); + } + + var nzbHash = ComputeMd5Hash(nzbLink); + var nzbNewTorrent = await AddQueued(nzbHash, nzbLink, false, DownloadType.Nzb, torrent); + Log($"Adding {nzbLink} with hash {nzbHash} (nzb link) to queue"); + + await CopyAddedTorrent(nzbNewTorrent); + + return nzbNewTorrent; + } + + public virtual async Task AddNzbFileToDebridQueue(Byte[] bytes, String? fileName, Torrent torrent) + { + torrent.RdName = fileName ?? "Unknown NZB"; + torrent.RdStatus = TorrentStatus.Queued; + try + { + using var stream = new MemoryStream(bytes); + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Ignore, + XmlResolver = null + }; + using var reader = XmlReader.Create(stream, settings); + var doc = XDocument.Load(reader); + var nzbNamespace = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; + + var title = doc.Root? + .Elements(nzbNamespace + "head") + .Elements(nzbNamespace + "meta") + .FirstOrDefault(x => x.Attribute("type")?.Value == "name")? + .Value; + + if (String.IsNullOrWhiteSpace(title)) + { + title = doc.Root? + .Elements(nzbNamespace + "head") + .Elements(nzbNamespace + "meta") + .FirstOrDefault(x => x.Attribute("type")?.Value == "title")? + .Value; + } + + if (!String.IsNullOrWhiteSpace(title)) + { + torrent.RdName = title.Trim(); + } + } + catch(Exception ex) + { + logger.LogError(ex, "{ex.Message}, trying to parse NZB file contents", ex.Message); + throw new($"{ex.Message}, trying to parse NZB file contents"); + } + + var nzbHash = ComputeMd5HashFromBytes(bytes); + var nzbFileAsBase64 = Convert.ToBase64String(bytes); + var nzbNewTorrent = await AddQueued(nzbHash, nzbFileAsBase64, true, DownloadType.Nzb, torrent); + Log($"Adding {nzbHash} (nzb file) to queue", nzbNewTorrent); + + await CopyAddedTorrent(nzbNewTorrent); + + return nzbNewTorrent; + } + + public virtual async Task AddMagnetToDebridQueue(String magnetLink, Torrent torrent) { var enriched = await enricher.EnrichMagnetLink(magnetLink); MagnetLink magnet; @@ -155,22 +233,22 @@ public class Torrents( torrent.RdName = magnet.Name; var hash = magnet.InfoHashes.V1OrV2.ToHex(); - var newTorrent = await AddQueued(hash, enriched, false, torrent); + var newTorrent = await AddQueued(hash, enriched, false, DownloadType.Torrent, torrent); Log($"Adding {hash} (magnet link) to queue", newTorrent); - await CopyAddedTorrent(magnet.Name!, magnetLink); + await CopyAddedTorrent(newTorrent); return newTorrent; } - public async Task AddFileToDebridQueue(Byte[] bytes, Torrent torrent) + public virtual async Task AddFileToDebridQueue(Byte[] bytes, Torrent torrent) { - MonoTorrent.Torrent monoTorrent; - var enriched = await enricher.EnrichTorrentBytes(bytes); String fileAsBase64; + MonoTorrent.Torrent monoTorrent; + if (enriched.SequenceEqual(bytes)) { fileAsBase64 = Convert.ToBase64String(bytes); @@ -228,56 +306,61 @@ public class Torrents( var hash = monoTorrent.InfoHashes.V1OrV2.ToHex(); - var newTorrent = await AddQueued(hash, fileAsBase64, true, torrent); + var newTorrent = await AddQueued(hash, fileAsBase64, true, DownloadType.Torrent, torrent); Log($"Adding {hash} (torrent file) to queue", newTorrent); - await CopyAddedTorrent(monoTorrent.Name, bytes); + await CopyAddedTorrent(newTorrent); return newTorrent; } - private async Task CopyAddedTorrent(String torrentName, Object fileOrMagnet) + private async Task CopyAddedTorrent(Torrent torrent) { - if (!String.IsNullOrWhiteSpace(Settings.Get.General.CopyAddedTorrents)) + if (String.IsNullOrWhiteSpace(Settings.Get.General.CopyAddedTorrents) || String.IsNullOrWhiteSpace(torrent.FileOrMagnet) || String.IsNullOrWhiteSpace(torrent.RdName)) { - try + return; + } + + try + { + if (!fileSystem.Directory.Exists(Settings.Get.General.CopyAddedTorrents)) { - if (!Directory.Exists(Settings.Get.General.CopyAddedTorrents)) - { - Directory.CreateDirectory(Settings.Get.General.CopyAddedTorrents); - } - - var copyFileName = Path.Combine(Settings.Get.General.CopyAddedTorrents, FileHelper.RemoveInvalidFileNameChars(torrentName)); - - copyFileName = fileOrMagnet switch - { - String => $"{copyFileName}.magnet", - Byte[] => $"{copyFileName}.torrent", - _ => throw new ArgumentException("Unexpected type for fileOrMagnet") - }; - - if (File.Exists(copyFileName)) - { - File.Delete(copyFileName); - } - - switch (fileOrMagnet) - { - case String magnetLink: - await File.WriteAllTextAsync(copyFileName, magnetLink); - - break; - case Byte[] torrentFile: - await File.WriteAllBytesAsync(copyFileName, torrentFile); - - break; - } + fileSystem.Directory.CreateDirectory(Settings.Get.General.CopyAddedTorrents); } - catch (Exception ex) + + var extension = torrent.Type switch { - logger.LogError(ex, $"Unable to create torrent blackhole directory: {Settings.Get.General.CopyAddedTorrents}: {ex.Message}"); + DownloadType.Nzb => ".nzb", + DownloadType.Torrent => torrent.IsFile ? ".torrent" : ".magnet", + _ => throw new ArgumentException("Unexpected DownloadType") + }; + + var copyFileName = Path.Combine(Settings.Get.General.CopyAddedTorrents, FileHelper.RemoveInvalidFileNameChars(torrent.RdName)); + + if (!copyFileName.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + { + copyFileName += extension; } + + if (fileSystem.File.Exists(copyFileName)) + { + fileSystem.File.Delete(copyFileName); + } + + if (torrent.IsFile) + { + var bytes = Convert.FromBase64String(torrent.FileOrMagnet); + await fileSystem.File.WriteAllBytesAsync(copyFileName, bytes); + } + else + { + await fileSystem.File.WriteAllTextAsync(copyFileName, torrent.FileOrMagnet); + } + } + catch (Exception ex) + { + logger.LogError(ex, $"Unable to create torrent blackhole directory: {Settings.Get.General.CopyAddedTorrents}: {ex.Message}"); } } @@ -305,9 +388,20 @@ public class Torrents( try { - var id = torrent.IsFile - ? await TorrentClient.AddFile(Convert.FromBase64String(torrent.FileOrMagnet)) - : await TorrentClient.AddMagnet(torrent.FileOrMagnet); + String id; + + if (torrent.Type == DownloadType.Nzb) + { + id = torrent.IsFile + ? await DebridClient.AddNzbFile(Convert.FromBase64String(torrent.FileOrMagnet), torrent.RdName) + : await DebridClient.AddNzbLink(torrent.FileOrMagnet); + } + else + { + id = torrent.IsFile + ? await DebridClient.AddTorrentFile(Convert.FromBase64String(torrent.FileOrMagnet)) + : await DebridClient.AddTorrentMagnet(torrent.FileOrMagnet); + } await torrentData.UpdateRdId(torrent, id); @@ -319,9 +413,9 @@ public class Torrents( } } - public async Task> GetAvailableFiles(String hash) + public async Task> GetAvailableFiles(String hash) { - var result = await TorrentClient.GetAvailableFiles(hash); + var result = await DebridClient.GetAvailableFiles(hash); return result; } @@ -335,7 +429,7 @@ public class Torrents( return; } - var selected = await TorrentClient.SelectFiles(torrent); + var selected = await DebridClient.SelectFiles(torrent); if (selected == 0) { @@ -352,7 +446,7 @@ public class Torrents( return; } - var downloadInfos = await TorrentClient.GetDownloadInfos(torrent); + var downloadInfos = await DebridClient.GetDownloadInfos(torrent); if (downloadInfos == null) { @@ -394,7 +488,7 @@ public class Torrents( await torrentData.UpdateComplete(torrent.TorrentId, "All files excluded", DateTimeOffset.Now, false); } - public async Task Delete(Guid torrentId, Boolean deleteData, Boolean deleteRdTorrent, Boolean deleteLocalFiles) + public virtual async Task Delete(Guid torrentId, Boolean deleteData, Boolean deleteRdTorrent, Boolean deleteLocalFiles) { var torrent = await GetById(torrentId); @@ -460,7 +554,7 @@ public class Torrents( try { - await TorrentClient.Delete(torrent.RdId); + await DebridClient.Delete(torrent); } catch { @@ -509,25 +603,21 @@ public class Torrents( Log("Unrestricting link", download, download.Torrent); - var unrestrictedLink = await TorrentClient.Unrestrict(download.Path); + var unrestrictedLink = await DebridClient.Unrestrict(download.Torrent!, download.Path); await downloads.UpdateUnrestrictedLink(downloadId, unrestrictedLink); return unrestrictedLink; } - /// - /// To be called only when . - /// is not set by - /// - /// + /// public async Task RetrieveFileName(Guid downloadId) { var download = await downloads.GetById(downloadId) ?? throw new($"Download with ID {downloadId} not found"); - Log($"Retrieving filename for", download, download.Torrent); + Log($"Retrieving filename for", download, download.Torrent!); - var fileName = await TorrentClient.GetFileName(download); + var fileName = await DebridClient.GetFileName(download); await downloads.UpdateFileName(downloadId, fileName); @@ -536,7 +626,7 @@ public class Torrents( public async Task GetProfile() { - var user = await TorrentClient.GetUser(); + var user = await DebridClient.GetUser(); var profile = new Profile { @@ -560,7 +650,7 @@ public class Torrents( try { - var rdTorrents = await TorrentClient.GetTorrents(); + var rdTorrents = await DebridClient.GetDownloads(); foreach (var rdTorrent in rdTorrents) { @@ -594,7 +684,7 @@ public class Torrents( continue; } - torrent = await torrentData.Add(rdTorrent.Id, rdTorrent.Hash, null, false, Settings.Get.DownloadClient.Client, newTorrent); + torrent = await torrentData.Add(rdTorrent.Id, rdTorrent.Hash, null, false, DownloadType.Torrent, Settings.Get.DownloadClient.Client, newTorrent); await UpdateTorrentClientData(torrent, rdTorrent); } @@ -670,15 +760,31 @@ public class Torrents( Torrent newTorrent; - if (torrent.IsFile) + if (torrent.Type == DownloadType.Nzb) { - var bytes = Convert.FromBase64String(torrent.FileOrMagnet); + if (torrent.IsFile) + { + var bytes = Convert.FromBase64String(torrent.FileOrMagnet!); - newTorrent = await AddFileToDebridQueue(bytes, torrent); + newTorrent = await AddNzbFileToDebridQueue(bytes, torrent.RdName, torrent); + } + else + { + newTorrent = await AddNzbLinkToDebridQueue(torrent.FileOrMagnet!, torrent); + } } else { - newTorrent = await AddMagnetToDebridQueue(torrent.FileOrMagnet, torrent); + if (torrent.IsFile) + { + var bytes = Convert.FromBase64String(torrent.FileOrMagnet!); + + newTorrent = await AddFileToDebridQueue(bytes, torrent); + } + else + { + newTorrent = await AddMagnetToDebridQueue(torrent.FileOrMagnet!, torrent); + } } await torrentData.UpdateRetry(newTorrent.TorrentId, null, retryCount); @@ -809,6 +915,7 @@ public class Torrents( private async Task AddQueued(String infoHash, String fileOrMagnetContents, Boolean isFile, + DownloadType downloadType, Torrent torrent) { var existingTorrent = await torrentData.GetByHash(infoHash); @@ -822,6 +929,7 @@ public class Torrents( infoHash, fileOrMagnetContents, isFile, + downloadType, torrent.DownloadClient, torrent); @@ -931,13 +1039,13 @@ public class Torrents( } } - private async Task UpdateTorrentClientData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent = null) + private async Task UpdateTorrentClientData(Torrent torrent, DebridClientTorrent? torrentClientTorrent = null) { try { var originalTorrent = JsonSerializer.Serialize(torrent, JsonSerializerOptions); - await TorrentClient.UpdateData(torrent, torrentClientTorrent); + await DebridClient.UpdateData(torrent, torrentClientTorrent); var newTorrent = JsonSerializer.Serialize(torrent, JsonSerializerOptions); @@ -976,4 +1084,26 @@ public class Torrents( logger.LogDebug(message); } + private static String ComputeSha1Hash(String input) + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + var bytes = Encoding.UTF8.GetBytes(input); + var hashBytes = sha1.ComputeHash(bytes); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + + private static String ComputeMd5Hash(String input) + { + using var md5 = System.Security.Cryptography.MD5.Create(); + var bytes = Encoding.UTF8.GetBytes(input); + var hashBytes = md5.ComputeHash(bytes); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + + private static String ComputeMd5HashFromBytes(Byte[] bytes) + { + using var md5 = System.Security.Cryptography.MD5.Create(); + var hashBytes = md5.ComputeHash(bytes); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } } diff --git a/server/RdtClient.Service/Services/UnpackClient.cs b/server/RdtClient.Service/Services/UnpackClient.cs index 911c95f..077b45c 100644 --- a/server/RdtClient.Service/Services/UnpackClient.cs +++ b/server/RdtClient.Service/Services/UnpackClient.cs @@ -1,7 +1,7 @@ using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Service.Helpers; -using RdtClient.Service.Services.TorrentClients; +using RdtClient.Service.Services.DebridClients; using SharpCompress.Archives; using SharpCompress.Archives.Rar; using SharpCompress.Archives.Zip; @@ -106,7 +106,7 @@ public class UnpackClient(Download download, String destinationPath) if (_torrent.ClientKind == Provider.TorBox) { - TorBoxTorrentClient.MoveHashDirContents(extractPath, _torrent); + TorBoxDebridClient.MoveHashDirContents(extractPath, _torrent); } } catch (Exception ex) diff --git a/server/RdtClient.Web.Test/Controllers/SabnzbdControllerTest.cs b/server/RdtClient.Web.Test/Controllers/SabnzbdControllerTest.cs new file mode 100644 index 0000000..c156822 --- /dev/null +++ b/server/RdtClient.Web.Test/Controllers/SabnzbdControllerTest.cs @@ -0,0 +1,255 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using RdtClient.Data.Models.Sabnzbd; +using RdtClient.Service.Services; +using RdtClient.Web.Controllers; + +namespace RdtClient.Web.Test.Controllers; + +public class SabnzbdControllerTest +{ + private readonly Mock _sabnzbdMock; + private readonly Mock _authenticationMock; + private readonly SabnzbdController _controller; + + public SabnzbdControllerTest() + { + Data.Data.SettingData.Get.General.AuthenticationType = Data.Enums.AuthenticationType.None; + Data.Data.SettingData.Get.Provider.ApiKey = "test-api-key"; + + var torrentsMock = new Mock(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); + var sabnzbdLoggerMock = new Mock>(); + _sabnzbdMock = new Mock(sabnzbdLoggerMock.Object, torrentsMock.Object, null!); + var loggerMock = new Mock>(); + _authenticationMock = new Mock(null!, null!, null!); + + _controller = new SabnzbdController(loggerMock.Object, _sabnzbdMock.Object); + + var httpContext = new DefaultHttpContext(); + _controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + } + + [Fact] + public async Task GetQueue_ReturnsOk() + { + // Arrange + var queue = new SabnzbdQueue { NoOfSlots = 1 }; + _sabnzbdMock.Setup(s => s.GetQueue()).ReturnsAsync(queue); + + // Act + var result = await _controller.Queue(); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.NotNull(response.Queue); + Assert.Equal(1, response.Queue.NoOfSlots); + } + + [Fact] + public async Task GetQueue_Unauthorized_ReturnsOk_BecauseFilterIsSkippedInUnitTests() + { + // Arrange + Data.Data.SettingData.Get.General.AuthenticationType = Data.Enums.AuthenticationType.UserNamePassword; + var queue = new SabnzbdQueue { NoOfSlots = 1 }; + _sabnzbdMock.Setup(s => s.GetQueue()).ReturnsAsync(queue); + + // Act + var result = await _controller.Queue(); + + // Assert + // In unit tests, filters are not executed. + // We test the filter separately in SabnzbdAuthFilterTest.cs + Assert.IsType(result); + } + + [Fact] + public async Task GetQueue_WithMaAuth_ReturnsOk() + { + // Arrange + Data.Data.SettingData.Get.General.AuthenticationType = Data.Enums.AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext(); + httpContext.Request.QueryString = new QueryString("?ma_username=user&ma_password=pass"); + _controller.ControllerContext.HttpContext = httpContext; + + _authenticationMock.Setup(a => a.Login("user", "pass")).ReturnsAsync(Microsoft.AspNetCore.Identity.SignInResult.Success); + + var queue = new SabnzbdQueue { NoOfSlots = 1 }; + _sabnzbdMock.Setup(s => s.GetQueue()).ReturnsAsync(queue); + + // Act + var result = await _controller.Queue(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetHistory_ReturnsOk() + { + // Arrange + var history = new SabnzbdHistory { NoOfSlots = 1 }; + _sabnzbdMock.Setup(s => s.GetHistory()).ReturnsAsync(history); + + // Act + var result = await _controller.History(); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.NotNull(response.History); + Assert.Equal(1, response.History.NoOfSlots); + } + + [Fact] + public void GetVersion_HasAllowAnonymousAttribute() + { + // Arrange + var type = typeof(SabnzbdController); + var method = type.GetMethod(nameof(SabnzbdController.Version)); + + // Act + var attribute = method?.GetCustomAttributes(typeof(AllowAnonymousAttribute), true).FirstOrDefault(); + + // Assert + Assert.NotNull(attribute); + } + + [Fact] + public void GetVersion_ReturnsOk() + { + // Arrange + Data.Data.SettingData.Get.General.AuthenticationType = Data.Enums.AuthenticationType.UserNamePassword; + + // Act + var result = _controller.Version(); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal("4.4.0", response.Version); + } + + [Fact] + public void GetConfig_ReturnsOk() + { + // Arrange + var config = new SabnzbdConfig { Misc = new SabnzbdMisc { Port = "6500" } }; + _sabnzbdMock.Setup(s => s.GetConfig()).Returns(config); + + // Act + var result = _controller.GetConfig(); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.NotNull(response.Config); + Assert.Equal("6500", response.Config.Misc.Port); + } + + [Fact] + public void GetCategories_ReturnsOk() + { + // Arrange + var categories = new List { "*", "Default" }; + _sabnzbdMock.Setup(s => s.GetCategories()).Returns(categories); + + // Act + var result = _controller.GetCats(); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.NotNull(response.Categories); + Assert.Equal(2, response.Categories.Count); + Assert.Equal("*", response.Categories[0]); + } + + [Fact] + public void Get_NoMode_ReturnsBadRequest() + { + // Act + var result = _controller.Get(null); + + // Assert + var badRequestResult = Assert.IsType(result); + var response = Assert.IsType(badRequestResult.Value); + Assert.Equal("No mode specified", response.Error); + } + + [Fact] + public void Get_UnknownMode_ReturnsNotFound() + { + // Act + var result = _controller.Get("unknown"); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Get_ModeInForm_ReturnsNotFound() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = "application/x-www-form-urlencoded"; + httpContext.Request.Form = new FormCollection(new Dictionary + { + { "mode", "unknown_form" } + }); + _controller.ControllerContext.HttpContext = httpContext; + + // Act + var result = _controller.Get(null); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task AddFile_WithQueryParameters_SetsCategoryAndPriority() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.QueryString = new QueryString("?cat=radarr&priority=-100"); + + // Mocking multipart form data + var fileMock = new Mock(); + var content = "test content"; + var fileName = "test.nzb"; + var ms = new MemoryStream(); + var writer = new StreamWriter(ms); + writer.Write(content); + writer.Flush(); + ms.Position = 0; + fileMock.Setup(_ => _.OpenReadStream()).Returns(ms); + fileMock.Setup(_ => _.FileName).Returns(fileName); + fileMock.Setup(_ => _.Length).Returns(ms.Length); + fileMock.Setup(_ => _.CopyToAsync(It.IsAny(), It.IsAny())) + .Callback((s, _) => ms.CopyTo(s)) + .Returns(Task.CompletedTask); + + httpContext.Request.ContentType = "multipart/form-data; boundary=something"; + httpContext.Request.Form = new FormCollection(new Dictionary(), new FormFileCollection { fileMock.Object }); + + _controller.ControllerContext.HttpContext = httpContext; + _sabnzbdMock.Setup(s => s.AddFile(It.IsAny(), fileName, "radarr", -100)).ReturnsAsync("nzo_id_123"); + + // Act + var result = await _controller.AddFile(); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.True(response.Status); + Assert.Contains("nzo_id_123", response.NzoIds!); + _sabnzbdMock.Verify(s => s.AddFile(It.IsAny(), fileName, "radarr", -100), Times.Once); + } +} diff --git a/server/RdtClient.Web.Test/Controllers/SabnzbdHandlerTest.cs b/server/RdtClient.Web.Test/Controllers/SabnzbdHandlerTest.cs new file mode 100644 index 0000000..764e917 --- /dev/null +++ b/server/RdtClient.Web.Test/Controllers/SabnzbdHandlerTest.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Moq; +using RdtClient.Data.Enums; +using RdtClient.Service.Middleware; +using RdtClient.Service.Services; + +namespace RdtClient.Web.Test.Controllers; + +public class SabnzbdHandlerTest +{ + private readonly Mock _authenticationMock; + private readonly Mock _httpContextAccessorMock; + private readonly SabnzbdHandler _handler; + + public SabnzbdHandlerTest() + { + _authenticationMock = new Mock(null!, null!, null!); + _httpContextAccessorMock = new Mock(); + _handler = new SabnzbdHandler(_authenticationMock.Object, _httpContextAccessorMock.Object); + Data.Data.SettingData.Get.General.AuthenticationType = AuthenticationType.UserNamePassword; + } + + [Fact] + public async Task HandleAsync_AuthNone_Succeeds() + { + // Arrange + Data.Data.SettingData.Get.General.AuthenticationType = AuthenticationType.None; + var context = CreateContext(); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded, "HasSucceeded should be true because AuthenticationType is None"); + } + + [Fact] + public async Task HandleAsync_ValidCredentials_Succeeds() + { + // Arrange + Settings.Get.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext(); + httpContext.Request.QueryString = new QueryString("?ma_username=user&ma_password=pass"); + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + _authenticationMock.Setup(a => a.Login("user", "pass")).ReturnsAsync(Microsoft.AspNetCore.Identity.SignInResult.Success); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded, "HasSucceeded should be true for valid credentials"); + } + + [Fact] + public async Task HandleAsync_AlreadyAuthenticated_Succeeds() + { + // Arrange + Settings.Get.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext(); + var claimsPrincipal = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity("TestAuth")); + httpContext.User = claimsPrincipal; + + var context = CreateContext(httpContext); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded, "HasSucceeded should be true for already authenticated user"); + _authenticationMock.Verify(a => a.Login(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_InvalidCredentials_DoesNotSucceed() + { + // Arrange + Settings.Get.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext(); + httpContext.Request.QueryString = new QueryString("?ma_username=user&ma_password=wrong"); + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + _authenticationMock.Setup(a => a.Login("user", "wrong")).ReturnsAsync(Microsoft.AspNetCore.Identity.SignInResult.Failed); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded, "HasSucceeded should be false for invalid credentials"); + } + + [Fact] + public async Task HandleAsync_MissingCredentials_DoesNotSucceed() + { + // Arrange + Settings.Get.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext(); + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded, "HasSucceeded should be false when credentials are missing"); + } + + private AuthorizationHandlerContext CreateContext(HttpContext? httpContext = null) + { + var requirement = new SabnzbdRequirement(); + var user = httpContext?.User ?? new System.Security.Claims.ClaimsPrincipal(); + return new AuthorizationHandlerContext([requirement], user, null); + } +} diff --git a/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs b/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs new file mode 100644 index 0000000..8c315c6 --- /dev/null +++ b/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using RdtClient.Data.Models.Data; +using RdtClient.Service.Services; +using RdtClient.Web.Controllers; + +namespace RdtClient.Web.Test.Controllers; + +public class TorrentsControllerNzbTest +{ + private readonly Mock _torrentsMock; + private readonly Mock> _loggerMock; + private readonly TorrentsController _controller; + + public TorrentsControllerNzbTest() + { + _torrentsMock = new Mock(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); + _loggerMock = new Mock>(); + _controller = new TorrentsController(_loggerMock.Object, _torrentsMock.Object, null!); + } + + [Fact] + public async Task UploadNzbLink_ValidRequest_ReturnsOk() + { + // Arrange + var request = new TorrentControllerUploadNzbLinkRequest + { + NzbLink = "http://example.com/test.nzb", + Torrent = new Torrent() + }; + + // Act + var result = await _controller.UploadNzbLink(request); + + // Assert + Assert.IsType(result); + _torrentsMock.Verify(t => t.AddNzbLinkToDebridQueue(request.NzbLink, request.Torrent), Times.Once); + } + + [Fact] + public async Task UploadNzbLink_NullRequest_ReturnsBadRequest() + { + // Act + var result = await _controller.UploadNzbLink(null); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task UploadNzbLink_EmptyLink_ReturnsBadRequest() + { + // Arrange + var request = new TorrentControllerUploadNzbLinkRequest + { + NzbLink = "", + Torrent = new Torrent() + }; + + // Act + var result = await _controller.UploadNzbLink(request); + + // Assert + var badRequest = Assert.IsType(result); + Assert.Equal("Invalid nzb link", badRequest.Value); + } + + [Fact] + public async Task UploadNzbFile_ValidRequest_ReturnsOk() + { + // Arrange + var fileMock = new Mock(); + var content = "nzb content"; + var fileName = "test.nzb"; + var ms = new MemoryStream(); + var writer = new StreamWriter(ms); + writer.Write(content); + writer.Flush(); + ms.Position = 0; + + fileMock.Setup(_ => _.OpenReadStream()).Returns(ms); + fileMock.Setup(_ => _.FileName).Returns(fileName); + fileMock.Setup(_ => _.Length).Returns(ms.Length); + + var formData = new TorrentControllerUploadFileRequest + { + Torrent = new Torrent() + }; + + // Act + var result = await _controller.UploadNzbFile(fileMock.Object, formData); + + // Assert + Assert.IsType(result); + _torrentsMock.Verify(t => t.AddNzbFileToDebridQueue(It.IsAny(), fileName, formData.Torrent), Times.Once); + Assert.Equal(fileName, formData.Torrent.RdName); + } + + [Fact] + public async Task UploadNzbFile_NoFile_ReturnsBadRequest() + { + // Act + var result = await _controller.UploadNzbFile(null, new TorrentControllerUploadFileRequest()); + + // Assert + var badRequest = Assert.IsType(result); + Assert.Equal("Invalid nzb file", badRequest.Value); + } +} diff --git a/server/RdtClient.Web.Test/RdtClient.Web.Test.csproj b/server/RdtClient.Web.Test/RdtClient.Web.Test.csproj new file mode 100644 index 0000000..bee50d5 --- /dev/null +++ b/server/RdtClient.Web.Test/RdtClient.Web.Test.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + RdtClient.Web.Test + RdtClient.Web.Test + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/server/RdtClient.Web/.config/dotnet-tools.json b/server/RdtClient.Web/.config/dotnet-tools.json index 22ec382..ef95326 100644 --- a/server/RdtClient.Web/.config/dotnet-tools.json +++ b/server/RdtClient.Web/.config/dotnet-tools.json @@ -3,10 +3,11 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "3.1.3", + "version": "9.0.9", "commands": [ "dotnet-ef" - ] + ], + "rollForward": false } } } \ No newline at end of file diff --git a/server/RdtClient.Web/Controllers/QBittorrentController.cs b/server/RdtClient.Web/Controllers/QBittorrentController.cs index 7627c96..b491eb2 100644 --- a/server/RdtClient.Web/Controllers/QBittorrentController.cs +++ b/server/RdtClient.Web/Controllers/QBittorrentController.cs @@ -13,6 +13,7 @@ namespace RdtClient.Web.Controllers; /// [ApiController] [Route("api/v2")] +[Route("qbittorrent/api/v2")] public class QBittorrentController(ILogger logger, QBittorrent qBittorrent) : Controller { [AllowAnonymous] diff --git a/server/RdtClient.Web/Controllers/SabnzbdController.cs b/server/RdtClient.Web/Controllers/SabnzbdController.cs new file mode 100644 index 0000000..ec98743 --- /dev/null +++ b/server/RdtClient.Web/Controllers/SabnzbdController.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using RdtClient.Data.Models.Sabnzbd; +using RdtClient.Service.Services; + +namespace RdtClient.Web.Controllers; + +[ApiController] +[Route("api")] +[Route("sabnzbd/api")] +[Authorize(Policy = "Sabnzbd")] +public class SabnzbdController(ILogger logger, Sabnzbd sabnzbd) : Controller +{ + [AllowAnonymous] + [HttpGet] + [HttpPost] + public ActionResult Get([FromQuery] String? mode) + { + if (Request.HasFormContentType) + { + mode ??= Request.Form["mode"].ToString(); + } + + if (String.IsNullOrWhiteSpace(mode)) + { + return BadRequest(new SabnzbdResponse { Error = "No mode specified" }); + } + + logger.LogWarning($"Sabnzbd API called (not implemented) - Method: {Request.Method}, Query: {Request.QueryString}"); + return NotFound(new SabnzbdResponse()); + } + + [AllowAnonymous] + [HttpGet] + [SabnzbdMode("version")] + public ActionResult Version() + { + logger.LogDebug("Sabnzbd mode: version"); + return Ok(new SabnzbdResponse { Version = "4.4.0" }); + } + + [HttpGet] + [SabnzbdMode("queue")] + public async Task Queue() + { + logger.LogDebug("Sabnzbd mode: queue"); + var name = GetParam("name"); + + if (name == "delete") + { + var value = GetParam("value"); + + if (String.IsNullOrWhiteSpace(value)) + { + return BadRequest(new SabnzbdResponse + { + Error = "No value specified for delete operation" + }); + } + await sabnzbd.Delete(value ?? ""); + return Ok(new SabnzbdResponse { Status = true }); + } + + return Ok(new SabnzbdResponse { Queue = await sabnzbd.GetQueue() }); + } + + [HttpGet] + [SabnzbdMode("history")] + public async Task History() + { + logger.LogDebug("Sabnzbd mode: history"); + return Ok(new SabnzbdResponse { History = await sabnzbd.GetHistory() }); + } + + [HttpGet] + [SabnzbdMode("get_config")] + public ActionResult GetConfig() + { + logger.LogDebug("Sabnzbd mode: get_config"); + return Ok(new SabnzbdResponse { Config = sabnzbd.GetConfig() }); + } + + [HttpGet] + [SabnzbdMode("get_cats")] + public ActionResult GetCats() + { + logger.LogDebug("Sabnzbd mode: get_cats"); + return Ok(new SabnzbdResponse { Categories = sabnzbd.GetCategories() }); + } + + [HttpGet] + [HttpPost] + [SabnzbdMode("addurl")] + public async Task AddUrl() + { + logger.LogDebug("Sabnzbd mode: addurl"); + var url = GetParam("name"); + var category = GetParam("cat"); + var priorityStr = GetParam("priority"); + + Int32? priority = Int32.TryParse(priorityStr, out var p) ? p : null; + + var result = await sabnzbd.AddUrl(url ?? "", category, priority); + return Ok(new SabnzbdResponse { Status = true, NzoIds = [result] }); + } + + [HttpPost] + [SabnzbdMode("addfile")] + public async Task AddFile() + { + logger.LogDebug("Sabnzbd mode: addfile"); + if (!Request.HasFormContentType) + { + return BadRequest("Expected multipart/form-data"); + } + + var file = Request.Form.Files.FirstOrDefault(); + if (file == null) + { + return BadRequest("No file uploaded"); + } + + var category = GetParam("cat"); + var priorityStr = GetParam("priority"); + Int32? priority = Int32.TryParse(priorityStr, out var p) ? p : null; + + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + var result = await sabnzbd.AddFile(ms.ToArray(), file.FileName, category, priority); + + return Ok(new SabnzbdResponse { Status = true, NzoIds = [result] }); + } + + [HttpGet] + [SabnzbdMode("fullstatus")] + public async Task FullStatus() + { + logger.LogDebug("Sabnzbd mode: fullstatus"); + return Ok(new SabnzbdResponse { Version = "4.4.0", Queue = await sabnzbd.GetQueue() }); + } + + private String? GetParam(String name) + { + var value = Request.Query[name].ToString(); + if (String.IsNullOrWhiteSpace(value) && Request.HasFormContentType) + { + value = Request.Form[name].ToString(); + } + return value; + } +} diff --git a/server/RdtClient.Web/Controllers/SabnzbdModeAttribute.cs b/server/RdtClient.Web/Controllers/SabnzbdModeAttribute.cs new file mode 100644 index 0000000..7f211b9 --- /dev/null +++ b/server/RdtClient.Web/Controllers/SabnzbdModeAttribute.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc.ActionConstraints; + +namespace RdtClient.Web.Controllers; + +[AttributeUsage(AttributeTargets.Method)] +public class SabnzbdModeAttribute(String mode) : Attribute, IActionConstraint +{ + public Int32 Order => 0; + + public Boolean Accept(ActionConstraintContext context) + { + var request = context.RouteContext.HttpContext.Request; + + String? modeValue = request.Query["mode"]; + + if (String.IsNullOrWhiteSpace(modeValue) && request.HasFormContentType) + { + modeValue = request.Form["mode"]; + } + + return String.Equals(modeValue, mode, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/server/RdtClient.Web/Controllers/TorrentsController.cs b/server/RdtClient.Web/Controllers/TorrentsController.cs index db6840c..2454102 100644 --- a/server/RdtClient.Web/Controllers/TorrentsController.cs +++ b/server/RdtClient.Web/Controllers/TorrentsController.cs @@ -2,8 +2,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MonoTorrent; +using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.Internal; -using RdtClient.Data.Models.TorrentClient; using RdtClient.Service.BackgroundServices; using RdtClient.Service.Helpers; using RdtClient.Service.Services; @@ -22,66 +22,65 @@ public class TorrentsController(ILogger logger, Torrents tor var results = await torrents.Get(); var torrentDtos = results.Select(torrent => new TorrentDto - { - TorrentId = torrent.TorrentId, - Hash = torrent.Hash, - Category = torrent.Category, - DownloadAction = torrent.DownloadAction, - FinishedAction = torrent.FinishedAction, - FinishedActionDelay = torrent.FinishedActionDelay, - HostDownloadAction = torrent.HostDownloadAction, - DownloadMinSize = torrent.DownloadMinSize, - IncludeRegex = torrent.IncludeRegex, - ExcludeRegex = torrent.ExcludeRegex, - DownloadManualFiles = torrent.DownloadManualFiles, - DownloadClient = torrent.DownloadClient, - Added = torrent.Added, - FilesSelected = torrent.FilesSelected, - Completed = torrent.Completed, - IsFile = torrent.IsFile, - Priority = torrent.Priority, - RetryCount = torrent.RetryCount, - DownloadRetryAttempts = torrent.DownloadRetryAttempts, - TorrentRetryAttempts = torrent.TorrentRetryAttempts, - DeleteOnError = torrent.DeleteOnError, - Lifetime = torrent.Lifetime, - Error = torrent.Error, - RdId = torrent.RdId, - RdName = torrent.RdName, - RdSize = torrent.RdSize, - RdHost = torrent.RdHost, - RdSplit = torrent.RdSplit, - RdProgress = torrent.RdProgress, - RdStatus = torrent.RdStatus, - RdStatusRaw = torrent.RdStatusRaw, - RdAdded = torrent.RdAdded, - RdEnded = torrent.RdEnded, - RdSpeed = torrent.RdSpeed, - RdSeeders = torrent.RdSeeders, - Files = torrent.Files, - Downloads = torrent.Downloads.Select(download => new DownloadDto - { - DownloadId = download.DownloadId, - TorrentId = download.TorrentId, - Path = download.Path, - Link = download.Link, - Added = download.Added, - DownloadQueued = download.DownloadQueued, - DownloadStarted = download.DownloadStarted, - DownloadFinished = download.DownloadFinished, - UnpackingQueued = download.UnpackingQueued, - UnpackingStarted = download.UnpackingStarted, - UnpackingFinished = download.UnpackingFinished, - Completed = download.Completed, - RetryCount = download.RetryCount, - Error = download.Error, - BytesTotal = download.BytesTotal, - BytesDone = download.BytesDone, - Speed = download.Speed - }) - .ToList() - }) - .ToList(); + { + TorrentId = torrent.TorrentId, + Hash = torrent.Hash, + Category = torrent.Category, + DownloadAction = torrent.DownloadAction, + FinishedAction = torrent.FinishedAction, + FinishedActionDelay = torrent.FinishedActionDelay, + HostDownloadAction = torrent.HostDownloadAction, + DownloadMinSize = torrent.DownloadMinSize, + IncludeRegex = torrent.IncludeRegex, + ExcludeRegex = torrent.ExcludeRegex, + DownloadManualFiles = torrent.DownloadManualFiles, + DownloadClient = torrent.DownloadClient, + Added = torrent.Added, + FilesSelected = torrent.FilesSelected, + Completed = torrent.Completed, + Type = torrent.Type, + IsFile = torrent.IsFile, + Priority = torrent.Priority, + RetryCount = torrent.RetryCount, + DownloadRetryAttempts = torrent.DownloadRetryAttempts, + TorrentRetryAttempts = torrent.TorrentRetryAttempts, + DeleteOnError = torrent.DeleteOnError, + Lifetime = torrent.Lifetime, + Error = torrent.Error, + RdId = torrent.RdId, + RdName = torrent.RdName, + RdSize = torrent.RdSize, + RdHost = torrent.RdHost, + RdSplit = torrent.RdSplit, + RdProgress = torrent.RdProgress, + RdStatus = torrent.RdStatus, + RdStatusRaw = torrent.RdStatusRaw, + RdAdded = torrent.RdAdded, + RdEnded = torrent.RdEnded, + RdSpeed = torrent.RdSpeed, + RdSeeders = torrent.RdSeeders, + Files = torrent.Files, + Downloads = torrent.Downloads.Select(download => new DownloadDto + { + DownloadId = download.DownloadId, + TorrentId = download.TorrentId, + Path = download.Path, + Link = download.Link, + Added = download.Added, + DownloadQueued = download.DownloadQueued, + DownloadStarted = download.DownloadStarted, + DownloadFinished = download.DownloadFinished, + UnpackingQueued = download.UnpackingQueued, + UnpackingStarted = download.UnpackingStarted, + UnpackingFinished = download.UnpackingFinished, + Completed = download.Completed, + RetryCount = download.RetryCount, + Error = download.Error, + BytesTotal = download.BytesTotal, + BytesDone = download.BytesDone, + Speed = download.Speed + }).ToList() + }).ToList(); return Ok(torrentDtos); } @@ -119,6 +118,7 @@ public class TorrentsController(ILogger logger, Torrents tor Added = torrent.Added, FilesSelected = torrent.FilesSelected, Completed = torrent.Completed, + Type = torrent.Type, IsFile = torrent.IsFile, Priority = torrent.Priority, RetryCount = torrent.RetryCount, @@ -141,26 +141,25 @@ public class TorrentsController(ILogger logger, Torrents tor RdSeeders = torrent.RdSeeders, Files = torrent.Files, Downloads = torrent.Downloads.Select(download => new DownloadDto - { - DownloadId = download.DownloadId, - TorrentId = download.TorrentId, - Path = download.Path, - Link = download.Link, - Added = download.Added, - DownloadQueued = download.DownloadQueued, - DownloadStarted = download.DownloadStarted, - DownloadFinished = download.DownloadFinished, - UnpackingQueued = download.UnpackingQueued, - UnpackingStarted = download.UnpackingStarted, - UnpackingFinished = download.UnpackingFinished, - Completed = download.Completed, - RetryCount = download.RetryCount, - Error = download.Error, - BytesTotal = download.BytesTotal, - BytesDone = download.BytesDone, - Speed = download.Speed - }) - .ToList() + { + DownloadId = download.DownloadId, + TorrentId = download.TorrentId, + Path = download.Path, + Link = download.Link, + Added = download.Added, + DownloadQueued = download.DownloadQueued, + DownloadStarted = download.DownloadStarted, + DownloadFinished = download.DownloadFinished, + UnpackingQueued = download.UnpackingQueued, + UnpackingStarted = download.UnpackingStarted, + UnpackingFinished = download.UnpackingFinished, + Completed = download.Completed, + RetryCount = download.RetryCount, + Error = download.Error, + BytesTotal = download.BytesTotal, + BytesDone = download.BytesDone, + Speed = download.Speed + }).ToList() }; return Ok(torrentDto); @@ -170,8 +169,7 @@ public class TorrentsController(ILogger logger, Torrents tor [Route("DiskSpaceStatus")] public ActionResult GetDiskSpaceStatus() { - var status = DiskSpaceMonitor.GetCurrentStatus(); - + var status = Service.BackgroundServices.DiskSpaceMonitor.GetCurrentStatus(); return Ok(status); } @@ -245,6 +243,68 @@ public class TorrentsController(ILogger logger, Torrents tor return Ok(); } + [HttpPost] + [Route("UploadNzbFile")] + public async Task UploadNzbFile([FromForm] IFormFile? file, + [ModelBinder(BinderType = typeof(JsonModelBinder))] + TorrentControllerUploadFileRequest? formData) + { + if (file == null || file.Length <= 0) + { + return BadRequest("Invalid nzb file"); + } + + if (formData?.Torrent == null) + { + return BadRequest("Invalid Torrent"); + } + + logger.LogDebug($"Add nzb file"); + + if (String.IsNullOrWhiteSpace(formData.Torrent.RdName)) + { + formData.Torrent.RdName = file.FileName; + } + + var fileStream = file.OpenReadStream(); + + await using var memoryStream = new MemoryStream(); + + await fileStream.CopyToAsync(memoryStream); + + var bytes = memoryStream.ToArray(); + + await torrents.AddNzbFileToDebridQueue(bytes, file.FileName, formData.Torrent); + + return Ok(); + } + + [HttpPost] + [Route("UploadNzbLink")] + public async Task UploadNzbLink([FromBody] TorrentControllerUploadNzbLinkRequest? request) + { + if (request == null) + { + return BadRequest(); + } + + if (String.IsNullOrEmpty(request.NzbLink)) + { + return BadRequest("Invalid nzb link"); + } + + if (request.Torrent == null) + { + return BadRequest("Invalid Torrent"); + } + + logger.LogDebug($"Add nzb link {request.NzbLink}"); + + await torrents.AddNzbLinkToDebridQueue(request.NzbLink, request.Torrent); + + return Ok(); + } + [HttpPost] [Route("CheckFiles")] public async Task CheckFiles([FromForm] IFormFile? file) @@ -355,7 +415,7 @@ public class TorrentsController(ILogger logger, Torrents tor var includeError = ""; var excludeError = ""; - IList availableFiles; + IList availableFiles; if (!String.IsNullOrWhiteSpace(request.MagnetLink)) { @@ -382,7 +442,7 @@ public class TorrentsController(ILogger logger, Torrents tor return BadRequest(); } - var selectedFiles = new List(); + var selectedFiles = new List(); if (!String.IsNullOrWhiteSpace(request.IncludeRegex)) { @@ -443,6 +503,12 @@ public class TorrentControllerUploadMagnetRequest public Torrent? Torrent { get; set; } } +public class TorrentControllerUploadNzbLinkRequest +{ + public String? NzbLink { get; set; } + public Torrent? Torrent { get; set; } +} + public class TorrentControllerDeleteRequest { public Boolean DeleteData { get; set; } diff --git a/server/RdtClient.Web/Program.cs b/server/RdtClient.Web/Program.cs index 824ad9f..55e5bb2 100644 --- a/server/RdtClient.Web/Program.cs +++ b/server/RdtClient.Web/Program.cs @@ -7,6 +7,7 @@ using RdtClient.Data.Models.Internal; using RdtClient.Service; using RdtClient.Service.Middleware; using RdtClient.Service.Services; +using RdtClient.Service.Helpers; using Serilog; using Serilog.Debugging; using Serilog.Events; @@ -41,6 +42,7 @@ builder.WebHost.ConfigureKestrel(options => if (appSettings.Logging?.File?.Path != null) { builder.Host.UseSerilog((_, lc) => lc.Enrich.FromLogContext() + .Enrich.With() .WriteTo.File(appSettings.Logging.File.Path, rollOnFileSizeLimit: true, fileSizeLimitBytes: appSettings.Logging.File.FileSizeLimitBytes, @@ -72,11 +74,8 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc }); builder.Services.AddAuthorizationBuilder() - .AddPolicy("AuthSetting", - policyCorrectUser => - { - policyCorrectUser.Requirements.Add(new AuthSettingRequirement()); - }); + .AddPolicy("AuthSetting", policyCorrectUser => { policyCorrectUser.Requirements.Add(new AuthSettingRequirement()); }) + .AddPolicy("Sabnzbd", policyCorrectUser => { policyCorrectUser.Requirements.Add(new SabnzbdRequirement()); }); builder.Services.AddIdentity(options => { diff --git a/server/RdtClient.sln b/server/RdtClient.sln index ea46a35..47f5129 100644 --- a/server/RdtClient.sln +++ b/server/RdtClient.sln @@ -11,28 +11,78 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RdtClient.Data", "RdtClient EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RdtClient.Service.Test", "RdtClient.Service.Test\RdtClient.Service.Test.csproj", "{4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RdtClient.Web.Test", "RdtClient.Web.Test\RdtClient.Web.Test.csproj", "{C9517091-EC0A-4146-85BA-58D95D0C597E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Debug|x64.Build.0 = Debug|Any CPU + {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Debug|x86.Build.0 = Debug|Any CPU {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Release|Any CPU.ActiveCfg = Release|Any CPU {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Release|Any CPU.Build.0 = Release|Any CPU + {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Release|x64.ActiveCfg = Release|Any CPU + {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Release|x64.Build.0 = Release|Any CPU + {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Release|x86.ActiveCfg = Release|Any CPU + {A8C7F095-89C6-4CD1-AFB2-27106F470D62}.Release|x86.Build.0 = Release|Any CPU {06D27710-AE9A-4FA2-B043-1A4303561716}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {06D27710-AE9A-4FA2-B043-1A4303561716}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06D27710-AE9A-4FA2-B043-1A4303561716}.Debug|x64.ActiveCfg = Debug|Any CPU + {06D27710-AE9A-4FA2-B043-1A4303561716}.Debug|x64.Build.0 = Debug|Any CPU + {06D27710-AE9A-4FA2-B043-1A4303561716}.Debug|x86.ActiveCfg = Debug|Any CPU + {06D27710-AE9A-4FA2-B043-1A4303561716}.Debug|x86.Build.0 = Debug|Any CPU {06D27710-AE9A-4FA2-B043-1A4303561716}.Release|Any CPU.ActiveCfg = Release|Any CPU {06D27710-AE9A-4FA2-B043-1A4303561716}.Release|Any CPU.Build.0 = Release|Any CPU + {06D27710-AE9A-4FA2-B043-1A4303561716}.Release|x64.ActiveCfg = Release|Any CPU + {06D27710-AE9A-4FA2-B043-1A4303561716}.Release|x64.Build.0 = Release|Any CPU + {06D27710-AE9A-4FA2-B043-1A4303561716}.Release|x86.ActiveCfg = Release|Any CPU + {06D27710-AE9A-4FA2-B043-1A4303561716}.Release|x86.Build.0 = Release|Any CPU {92EF8817-AD73-4301-93BD-745D7D61DD74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {92EF8817-AD73-4301-93BD-745D7D61DD74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92EF8817-AD73-4301-93BD-745D7D61DD74}.Debug|x64.ActiveCfg = Debug|Any CPU + {92EF8817-AD73-4301-93BD-745D7D61DD74}.Debug|x64.Build.0 = Debug|Any CPU + {92EF8817-AD73-4301-93BD-745D7D61DD74}.Debug|x86.ActiveCfg = Debug|Any CPU + {92EF8817-AD73-4301-93BD-745D7D61DD74}.Debug|x86.Build.0 = Debug|Any CPU {92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|Any CPU.ActiveCfg = Release|Any CPU {92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|Any CPU.Build.0 = Release|Any CPU + {92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|x64.ActiveCfg = Release|Any CPU + {92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|x64.Build.0 = Release|Any CPU + {92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|x86.ActiveCfg = Release|Any CPU + {92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|x86.Build.0 = Release|Any CPU {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|x64.Build.0 = Debug|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|x86.Build.0 = Debug|Any CPU {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|Any CPU.Build.0 = Release|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|x64.ActiveCfg = Release|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|x64.Build.0 = Release|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|x86.ActiveCfg = Release|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|x86.Build.0 = Release|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Debug|x64.ActiveCfg = Debug|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Debug|x64.Build.0 = Debug|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Debug|x86.ActiveCfg = Debug|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Debug|x86.Build.0 = Debug|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Release|Any CPU.Build.0 = Release|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Release|x64.ActiveCfg = Release|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Release|x64.Build.0 = Release|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Release|x86.ActiveCfg = Release|Any CPU + {C9517091-EC0A-4146-85BA-58D95D0C597E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE