Merge pull request #911 from omgbeez/usenet

Adding usenet support (Torbox only implementation)
This commit is contained in:
Roger Far 2026-02-18 21:29:21 -07:00 committed by GitHub
commit d0242f235c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 4784 additions and 868 deletions

View file

@ -1,5 +1,24 @@
<div class="field">
<div>
<div class="tabs is-toggle is-fullwidth">
<ul>
<li [class.is-active]="type === 'torrent'">
<a (click)="changeType('torrent')">
<span class="icon is-small"><i class="fas fa-magnet" aria-hidden="true"></i></span>
<span>Torrent</span>
</a>
</li>
<li [class.is-active]="type === 'nzb'">
<a (click)="changeType('nzb')">
<span class="icon is-small"><i class="fas fa-file-archive" aria-hidden="true"></i></span>
<span>NZB</span>
</a>
</li>
</ul>
</div>
</div>
<div class="field">
<div [hidden]="type !== 'torrent'">
<div class="field">
<label class="label">Torrent file</label>
<div class="file has-name">
@ -31,15 +50,53 @@
></textarea>
</div>
</div>
</div>
<div [hidden]="type !== 'nzb'">
<div class="field">
<div class="control">
<button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
<span>Add Torrent</span>
</button>
<label class="label">NZB file</label>
<div class="file has-name">
<label class="file-label">
<input class="file-input" type="file" name="resume" (change)="pickFile($event)" [disabled]="saving" />
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label"> Pick an NZB file... </span>
</span>
<span class="file-name">
{{ fileName }}
</span>
</label>
</div>
</div>
<div class="field">
<label class="label">NZB Link</label>
<div class="control">
<textarea
class="textarea"
placeholder="Paste your NZB link here"
[(ngModel)]="nzbLink"
[disabled]="saving"
></textarea>
</div>
</div>
</div>
<div class="field" style="margin-top: 10px">
<div class="control">
<button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
@if (type === 'torrent') {
<span>Add Torrent</span>
} @else {
<span>Add NZB</span>
}
</button>
</div>
</div>
<div>
<div class="separator">Advanced settings</div>
<div class="field">
@ -53,7 +110,7 @@
</select>
</div>
<p class="help">
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.
</p>
</div>
@ -66,7 +123,7 @@
</select>
</div>
<p class="help">
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.
</p>
</div>
@ -142,10 +199,10 @@
<select [(ngModel)]="finishedAction">
<option [ngValue]="0">Do nothing</option>
@if (downloadClient !== 2) {
<option [ngValue]="1">Remove torrent from provider and client</option>
<option [ngValue]="2">Remove torrent from provider</option>
<option [ngValue]="1">Remove from provider and client</option>
<option [ngValue]="2">Remove from provider</option>
}
<option [ngValue]="3">Remove torrent from client</option>
<option [ngValue]="3">Remove from client</option>
</select>
</div>
</div>
@ -156,7 +213,7 @@
<input class="input" type="number" [(ngModel)]="finishedActionDelay" />
</div>
<p class="help">
When a torrent is finished downloading on the provider, perform this action. Use this setting if you only want
When a download is finished on the provider, perform this action. Use this setting if you only want
to add files to your debrid provider but not download them to the host.
</p>
</div>
@ -175,9 +232,14 @@
<input class="input" type="number" step="1" [(ngModel)]="priority" />
</div>
<p class="help">
Set the priority for this torrent where 1 is the highest. When empty it will be assigned the lowest priority.
Set the priority where 1 is the highest. When empty it will be assigned the lowest priority.
</p>
</div>
</div>
</div>
<div class="field">
<div>
<div class="field">
<label class="label">Automatic retry downloads</label>
<div class="control">
@ -186,13 +248,13 @@
<p class="help">When a single download fails it will retry it this many times before marking it as failed.</p>
</div>
<div class="field">
<label class="label">Automatic retry torrent</label>
<label class="label">Automatic retry download</label>
<div class="control">
<input class="input" type="number" max="1000" min="0" step="1" [(ngModel)]="torrentRetryAttempts" />
</div>
<p class="help">
When a single download has failed multiple times (see setting above) or when the torrent itself received an
error it will retry the full torrent this many times before marking it failed.
When a single download has failed multiple times (see setting above) or when the download itself received an
error it will retry the full download this many times before marking it failed.
</p>
</div>
<div class="field">
@ -206,13 +268,13 @@
</p>
</div>
<div class="field">
<label class="label">Torrent maximum lifetime</label>
<label class="label">Maximum lifetime</label>
<div class="control">
<input class="input" type="number" max="100000" min="0" step="1" [(ngModel)]="torrentLifetime" />
</div>
<p class="help">
The maximum lifetime of a torrent in minutes. When this time has passed, mark the torrent as error. If the
torrent is completed and has downloads, the lifetime setting will not apply. 0 to disable.
The maximum lifetime of a download in minutes. When this time has passed, mark it as error. If the
download is completed, the lifetime setting will not apply. 0 to disable.
</p>
</div>
</div>
@ -226,10 +288,3 @@
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-success" [disabled]="saving" [ngClass]="{ 'is-loading': saving }" (click)="ok()">
<span>Add Torrent</span>
</button>
</div>
</div>

View file

@ -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;
}

View file

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

View file

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

View file

@ -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');

View file

@ -79,6 +79,20 @@ export class TorrentService {
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadFile`, formData);
}
public uploadNzbLink(nzbLink: string, torrent: Torrent): Observable<void> {
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadNzbLink`, {
nzbLink,
torrent,
});
}
public uploadNzbFile(file: File, torrent: Torrent): Observable<void> {
const formData: FormData = new FormData();
formData.append('file', file);
formData.append('formData', JSON.stringify({ torrent }));
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadNzbFile`, formData);
}
public checkFilesMagnet(magnetLink: string): Observable<TorrentFileAvailability[]> {
return this.http.post<TorrentFileAvailability[]>(`${this.baseHref}Api/Torrents/CheckFilesMagnet`, {
magnetLink,

View file

@ -13,6 +13,7 @@ public interface ITorrentData
String hash,
String? fileOrMagnetContents,
Boolean isFile,
DownloadType downloadType,
DownloadClient downloadClient,
Torrent torrent);

View file

@ -9,7 +9,7 @@ namespace RdtClient.Data.Data;
public class SettingData(DataContext dataContext, ILogger<SettingData> logger)
{
public static DbSettings Get { get; } = new();
public static DbSettings Get { get; private set; } = new();
public static IList<SettingProperty> GetAll()
{

View file

@ -12,11 +12,11 @@ public class TorrentData(DataContext dataContext) : ITorrentData
.AsNoTracking()
.AsSplitQuery()
.Include(m => m.Downloads)
.OrderBy(m => m.Priority ?? 9999)
.ThenBy(m => m.Added)
.ToListAsync();
return torrents;
return torrents.OrderBy(m => m.Priority ?? 9999)
.ThenBy(m => m.Added)
.ToList();
}
public async Task<Torrent?> GetById(Guid torrentId)
@ -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);

View file

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

View file

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

View file

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

View file

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

View file

@ -402,6 +402,9 @@ namespace RdtClient.Data.Migrations
b.Property<int>("TorrentRetryAttempts")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("TorrentId");
b.ToTable("Torrents");

View file

@ -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<TorrentClientFile> Files
public IList<DebridClientFile> Files
{
get
{
@ -72,7 +73,7 @@ public class Torrent
try
{
return JsonSerializer.Deserialize<List<TorrentClientFile>>(RdFiles) ?? [];
return JsonSerializer.Deserialize<List<DebridClientFile>>(RdFiles) ?? [];
}
catch
{

View file

@ -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!;

View file

@ -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!;

View file

@ -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<TorrentClientFile>? Files { get; set; }
public DownloadType Type { get; set; } = DownloadType.Torrent;
public List<DebridClientFile>? Files { get; set; }
public List<String>? Links { get; set; }
public DateTimeOffset? Ended { get; set; }
public Int64? Speed { get; set; }

View file

@ -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; }

View file

@ -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")]

View file

@ -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<TorrentClientFile> Files { get; set; } = [];
public IList<DebridClientFile> Files { get; set; } = [];
public IList<DownloadDto> Downloads { get; set; } = [];
}

View file

@ -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";
}

View file

@ -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<SabnzbdCategory> Categories { get; set; } = new();
[JsonPropertyName("servers")]
public List<Object> Servers { get; set; } = new();
}

View file

@ -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<SabnzbdHistorySlot> Slots { get; set; } = new();
}

View file

@ -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; } = "";
}

View file

@ -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";
}

View file

@ -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<SabnzbdQueueSlot> Slots { get; set; } = new();
}

View file

@ -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";
}

View file

@ -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<String>? NzoIds { get; set; }
[JsonPropertyName("config")]
public SabnzbdConfig? Config { get; set; }
[JsonPropertyName("categories")]
public List<String>? Categories { get; set; }
}

View file

@ -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<ILogger<WatchFolderChecker>> _loggerMock;
private readonly Mock<IServiceProvider> _serviceProviderMock;
private readonly Mock<IServiceScope> _serviceScopeMock;
private readonly Mock<IServiceProvider> _scopeServiceProviderMock;
private readonly Mock<Torrents> _torrentsServiceMock;
private readonly String _testPath;
public WatchFolderCheckerTests()
{
_loggerMock = new Mock<ILogger<WatchFolderChecker>>();
_serviceProviderMock = new Mock<IServiceProvider>();
_serviceScopeMock = new Mock<IServiceScope>();
_scopeServiceProviderMock = new Mock<IServiceProvider>();
_torrentsServiceMock = new Mock<Torrents>(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<Byte[]>(), It.IsAny<Torrent>()))
.ReturnsAsync(new Torrent());
// Act
var task = checker.StartAsync(cts.Token);
await Task.Delay(500); // Give it some time to process
await cts.CancelAsync();
try { await task; } catch (OperationCanceledException) { }
// Assert
_torrentsServiceMock.Verify(x => x.AddFileToDebridQueue(
It.Is<Byte[]>(b => b.SequenceEqual(content)),
It.IsAny<Torrent>()), Times.AtLeastOnce);
Assert.False(File.Exists(filePath));
Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.torrent")));
}
[Fact]
public async Task ExecuteAsync_ShouldProcessMagnetFile()
{
// Arrange
var filePath = Path.Combine(_testPath, "test.magnet");
var content = "magnet:?xt=urn:btih:123";
await File.WriteAllTextAsync(filePath, content);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
_torrentsServiceMock
.Setup(x => x.AddMagnetToDebridQueue(It.IsAny<String>(), It.IsAny<Torrent>()))
.ReturnsAsync(new Torrent());
// Act
var task = checker.StartAsync(cts.Token);
await Task.Delay(500);
await cts.CancelAsync();
try { await task; } catch (OperationCanceledException) { }
// Assert
_torrentsServiceMock.Verify(x => x.AddMagnetToDebridQueue(
content,
It.IsAny<Torrent>()), Times.AtLeastOnce);
Assert.False(File.Exists(filePath));
Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.magnet")));
}
[Fact]
public async Task ExecuteAsync_ShouldProcessNzbFile()
{
// Arrange
var filePath = Path.Combine(_testPath, "test.nzb");
var content = "nzb content"u8.ToArray();
await File.WriteAllBytesAsync(filePath, content);
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
_torrentsServiceMock
.Setup(x => x.AddNzbFileToDebridQueue(It.IsAny<Byte[]>(), It.IsAny<String>(), It.IsAny<Torrent>()))
.ReturnsAsync(new Torrent());
// Act
var task = checker.StartAsync(cts.Token);
await Task.Delay(500);
await cts.CancelAsync();
try { await task; } catch (OperationCanceledException) { }
// Assert
_torrentsServiceMock.Verify(x => x.AddNzbFileToDebridQueue(
It.Is<Byte[]>(b => b.SequenceEqual(content)),
"test.nzb",
It.IsAny<Torrent>()), Times.AtLeastOnce);
Assert.False(File.Exists(filePath));
Assert.True(File.Exists(Path.Combine(_testPath, "processed", "test.nzb")));
}
[Fact]
public async Task ExecuteAsync_ShouldIgnoreOtherFiles()
{
// Arrange
var filePath = Path.Combine(_testPath, "test.txt");
await File.WriteAllTextAsync(filePath, "ignore me");
var checker = new WatchFolderChecker(_loggerMock.Object, _serviceProviderMock.Object);
ResetPrevCheck(checker);
var cts = new CancellationTokenSource();
// Act
var task = checker.StartAsync(cts.Token);
await Task.Delay(500);
await cts.CancelAsync();
try { await task; } catch (OperationCanceledException) { }
// Assert
_torrentsServiceMock.Verify(x => x.AddFileToDebridQueue(It.IsAny<Byte[]>(), It.IsAny<Torrent>()), Times.Never);
_torrentsServiceMock.Verify(x => x.AddMagnetToDebridQueue(It.IsAny<String>(), It.IsAny<Torrent>()), Times.Never);
_torrentsServiceMock.Verify(x => x.AddNzbFileToDebridQueue(It.IsAny<Byte[]>(), It.IsAny<String>(), It.IsAny<Torrent>()), Times.Never);
Assert.True(File.Exists(filePath));
}
private class MockScopeFactory(IServiceScope scope) : IServiceScopeFactory
{
public IServiceScope CreateScope() => scope;
}
}

View file

@ -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<TorrentClientFile> files =
IList<DebridClientFile> files =
[
new()
{
@ -219,7 +219,7 @@ public class DownloadHelperTest
var fileRelativePath = "inside/lots/of/subdirectories/file.txt";
IList<TorrentClientFile> files =
IList<DebridClientFile> files =
[
new()
{

View file

@ -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);
}
}

View file

@ -20,6 +20,7 @@
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
<PackageReference Include="TorBox.NET" Version="1.6.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
@ -35,4 +36,10 @@
<ProjectReference Include="..\RdtClient.Service\RdtClient.Service.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="TorBoxNET">
<HintPath>..\..\..\torbox-net\TorBoxNET\bin\Release\netstandard2.0\TorBoxNET.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -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<String>())).ReturnsAsync((Torrent)null!);
_mocks.TorrentDataMock.Setup(t => t.Add(
null,
It.IsAny<String>(),
nzbLink,
false,
DownloadType.Nzb,
torrent.DownloadClient,
It.IsAny<Torrent>()
)).ReturnsAsync(new Torrent { Hash = "mockHash", RdName = "test.nzb" });
// Act
var result = await _torrents.AddNzbLinkToDebridQueue(nzbLink, torrent);
// Assert
Assert.NotNull(result);
Assert.Equal("test.nzb", torrent.RdName);
Assert.Equal(TorrentStatus.Queued, torrent.RdStatus);
_mocks.TorrentDataMock.Verify(t => t.Add(
null,
It.IsAny<String>(),
nzbLink,
false,
DownloadType.Nzb,
torrent.DownloadClient,
torrent
), Times.Once);
}
[Fact]
public async Task AddNzbLinkToDebridQueue_InvalidLink_ThrowsException()
{
// Arrange
var nzbLink = "invalid-link";
var torrent = new Torrent();
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _torrents.AddNzbLinkToDebridQueue(nzbLink, torrent));
}
[Fact]
public async Task AddNzbFileToDebridQueue_ValidFile_AddsToQueue()
{
// Arrange
var nzbContent = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\r\n<nzb xmlns=\"http://www.newzbin.com/DTD/2003/nzb\">\r\n <head>\r\n <meta type=\"title\">Test NZB Title</meta>\r\n </head>\r\n</nzb>";
var bytes = Encoding.UTF8.GetBytes(nzbContent);
var torrent = new Torrent
{
DownloadClient = DownloadClient.Bezzad
};
_mocks.TorrentDataMock.Setup(t => t.GetByHash(It.IsAny<String>())).ReturnsAsync((Torrent)null!);
_mocks.TorrentDataMock.Setup(t => t.Add(
null,
It.IsAny<String>(),
It.IsAny<String>(),
true,
DownloadType.Nzb,
torrent.DownloadClient,
It.IsAny<Torrent>()
)).ReturnsAsync(new Torrent { Hash = "mockHash", RdName = "Test NZB Title" });
// Act
var result = await _torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent);
// Assert
Assert.NotNull(result);
Assert.Equal("Test NZB Title", torrent.RdName);
Assert.Equal(TorrentStatus.Queued, torrent.RdStatus);
_mocks.TorrentDataMock.Verify(t => t.Add(
null,
It.IsAny<String>(),
Convert.ToBase64String(bytes),
true,
DownloadType.Nzb,
torrent.DownloadClient,
torrent
), Times.Once);
}
[Fact]
public async Task AddNzbFileToDebridQueue_InvalidXml_ThrowsException()
{
// Arrange
var bytes = Encoding.UTF8.GetBytes("not xml");
var torrent = new Torrent();
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent));
}
}

View file

@ -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<ILogger<QBittorrent>> _loggerMock;
private readonly Mock<Torrents> _torrentsMock;
private readonly Mock<Authentication> _authenticationMock;
private readonly QBittorrent _qBittorrent;
public QBittorrentTest()
{
_loggerMock = new Mock<ILogger<QBittorrent>>();
_torrentsMock = new Mock<Torrents>(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
_authenticationMock = new Mock<Authentication>(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<Torrent>
{
new()
{
TorrentId = Guid.NewGuid(),
Hash = "hash1",
RdName = "Torrent 1",
Type = DownloadType.Torrent
},
new()
{
TorrentId = Guid.NewGuid(),
Hash = "hash2",
RdName = "NZB 1",
Type = DownloadType.Nzb
}
};
_torrentsMock.Setup(m => m.Get()).ReturnsAsync(allTorrents);
// Act
var result = await _qBittorrent.TorrentInfo();
// Assert
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
Assert.Equal("Torrent 1", result[0].Name);
}
}

View file

@ -0,0 +1,400 @@
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<ILogger<Sabnzbd>> _loggerMock = new();
private readonly Mock<Torrents> _torrentsMock;
private readonly AppSettings _appSettings = new() { Port = 6500 };
public SabnzbdTest()
{
_torrentsMock = new Mock<Torrents>(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(new List<Torrent>());
}
[Fact]
public async Task GetQueue_ShouldReturnCorrectStructure()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "Name 1",
RdProgress = 50,
Type = DownloadType.Nzb,
Downloads = new List<Download>
{
new()
{ BytesTotal = 1000, BytesDone = 500 }
}
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
_torrentsMock.Setup(t => t.GetDownloadStats(It.IsAny<Guid>())).Returns((0, 1000, 500));
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
Assert.NotNull(result);
Assert.Single(result.Slots);
Assert.Equal("hash1", result.Slots[0].NzoId);
Assert.Equal("Name 1", result.Slots[0].Filename);
// (50% debrid + 50% download) / 2 = 50%
Assert.Equal("50", result.Slots[0].Percentage);
}
[Fact]
public async Task GetQueue_ShouldCalculatePercentageCorrectly_WhenDifferentProgress()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "Name 1",
RdProgress = 100,
Type = DownloadType.Nzb,
Downloads = new List<Download>
{
new()
{ BytesTotal = 1000, BytesDone = 0 }
}
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
// (100% debrid + 0% download) / 2 = 50%
Assert.Equal("50", result.Slots[0].Percentage);
}
[Fact]
public async Task GetQueue_ShouldCalculateTimeLeftCorrectly()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var added = now.AddMinutes(-10);
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "Name 1",
Added = added,
RdProgress = 100, // 100% debrid
Type = DownloadType.Nzb,
Downloads = new List<Download>
{
new()
{
BytesTotal = 1000,
BytesDone = 0 // 0% download
}
}
}
};
// Overall progress = (1.0 + 0.0) / 2 = 0.5
// Elapsed = 10 minutes
// Total estimated = 10 / 0.5 = 20 minutes
// Time left = 20 - 10 = 10 minutes
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
// Allow for some small variation in time calculation during test execution
var timeLeftParts = result.Slots[0].TimeLeft.Split(':');
var hours = Int32.Parse(timeLeftParts[0]);
var minutes = Int32.Parse(timeLeftParts[1]);
Assert.Equal(0, hours);
Assert.InRange(minutes, 9, 11);
}
[Fact]
public async Task GetQueue_ShouldCalculateTimeLeftCorrectly_WithRetry()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var added = now.AddMinutes(-20);
var retry = now.AddMinutes(-10);
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "Name 1",
Added = added,
Retry = retry,
RdProgress = 100, // 100% debrid
Type = DownloadType.Nzb,
Downloads = new List<Download>
{
new()
{
BytesTotal = 1000,
BytesDone = 0 // 0% download
}
}
}
};
// Later of Added and Retry is Retry (-10 mins)
// Overall progress = (1.0 + 0.0) / 2 = 0.5
// Elapsed = 10 minutes
// Total estimated = 10 / 0.5 = 20 minutes
// Time left = 20 - 10 = 10 minutes
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
var timeLeftParts = result.Slots[0].TimeLeft.Split(':');
var hours = Int32.Parse(timeLeftParts[0]);
var minutes = Int32.Parse(timeLeftParts[1]);
Assert.Equal(0, hours);
Assert.InRange(minutes, 9, 11);
}
[Fact]
public async Task GetQueue_ShouldOnlyReturnNzbs()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Type = DownloadType.Nzb,
Downloads = new List<Download>()
},
new()
{
Hash = "hash2",
RdName = "Torrent 1",
Type = DownloadType.Torrent,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// Act
var result = await sabnzbd.GetQueue();
// Assert
Assert.Single(result.Slots);
Assert.Equal("hash1", result.Slots[0].NzoId);
}
[Fact]
public async Task GetHistory_ShouldOnlyReturnNzbs()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Type = DownloadType.Nzb,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>()
},
new()
{
Hash = "hash2",
RdName = "Torrent 1",
Type = DownloadType.Torrent,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// 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<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Category = "radarr",
Type = DownloadType.Nzb,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// 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<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Category = null,
Type = DownloadType.Nzb,
Completed = DateTimeOffset.UtcNow,
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// Act
var result = await sabnzbd.GetHistory();
// Assert
Assert.Single(result.Slots);
var expectedPath = Path.Combine(savePath, "NZB 1");
Assert.Equal(expectedPath, result.Slots[0].Path);
}
[Fact]
public async Task GetHistory_ShouldReturnFailedStatus_WhenTorrentHasError()
{
// Arrange
var torrentList = new List<Torrent>
{
new()
{
Hash = "hash1",
RdName = "NZB 1",
Type = DownloadType.Nzb,
Completed = DateTimeOffset.UtcNow,
Error = "Some error occurred",
Downloads = new List<Download>()
}
};
_torrentsMock.Setup(t => t.Get()).ReturnsAsync(torrentList);
var sabnzbd = new Sabnzbd(_loggerMock.Object, _torrentsMock.Object, _appSettings);
// 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<Torrent>
{
new()
{
Hash = "hash1",
Category = "Movie",
Type = DownloadType.Nzb,
Downloads = new List<Download>()
}
};
_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);
}
}

View file

@ -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<CancellationToken>())).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<CancellationToken>()))
.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<CancellationToken>())).ReturnsAsync(files);
mocks.FileFilterMock.Setup(f => f.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).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<IAllDebridNetClientFactory> AllDebridClientFactoryMock;
public readonly Mock<IAllDebridNETClient> AllDebridClientMock;
public readonly Mock<ILogger<AllDebridDebridClient>> LoggerMock;
public readonly Mock<IDownloadableFileFilter> FileFilterMock;
public readonly Mock<ILogger<AllDebridTorrentClient>> LoggerMock;
public Mocks()
{

View file

@ -0,0 +1,513 @@
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<ILogger<TorBoxDebridClient>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<IDownloadableFileFilter> _fileFilterMock;
public TorBoxDebridClientTest()
{
_loggerMock = new Mock<ILogger<TorBoxDebridClient>>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_fileFilterMock = new Mock<IDownloadableFileFilter>();
var httpClient = new HttpClient();
_httpClientFactoryMock.Setup(m => m.CreateClient(It.IsAny<String>())).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<TorrentInfoResult>
{
new() { Hash = "hash1", Name = "torrent1", Size = 1000, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }
};
var nzbs = new List<UsenetInfoResult>
{
new() { Hash = "hash2", Name = "nzb1", Size = 2000, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
clientMock.Protected().Setup<Task<IEnumerable<TorrentInfoResult>?>>("GetCurrentTorrents").ReturnsAsync(torrents);
clientMock.Protected().Setup<Task<IEnumerable<TorrentInfoResult>?>>("GetQueuedTorrents").ReturnsAsync(new List<TorrentInfoResult>());
clientMock.Protected().Setup<Task<IEnumerable<UsenetInfoResult>?>>("GetCurrentUsenet").ReturnsAsync(nzbs);
clientMock.Protected().Setup<Task<IEnumerable<UsenetInfoResult>?>>("GetQueuedUsenet").ReturnsAsync(new List<UsenetInfoResult>());
// 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<List<AvailableTorrent?>>
{
Data = new List<AvailableTorrent?>
{
new()
{
Files = new List<AvailableTorrentFile>
{
new() { Name = "file1.mkv", Size = 100 },
new() { Name = "file2.txt", Size = 10 }
}
}
}
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
clientMock.Protected().Setup<Task<Response<List<AvailableTorrent?>>>>("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<List<AvailableTorrent?>> { Data = new List<AvailableTorrent?>() };
var usenetAvailability = new Response<List<AvailableUsenet?>>
{
Data = new List<AvailableUsenet?>
{
new()
{
Files = new List<AvailableUsenetFile>
{
new() { Name = "file1.nzb", Size = 200 }
}
}
}
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
clientMock.Protected().Setup<Task<Response<List<AvailableTorrent?>>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability);
clientMock.Protected().Setup<Task<Response<List<AvailableUsenet?>>>>("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<List<AvailableTorrent?>> { Data = new List<AvailableTorrent?>() };
var usenetAvailability = new Response<List<AvailableUsenet?>> { Data = new List<AvailableUsenet?>() };
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
clientMock.Protected().Setup<Task<Response<List<AvailableTorrent?>>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability);
clientMock.Protected().Setup<Task<Response<List<AvailableUsenet?>>>>("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<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
// Act
await clientMock.Object.Delete(torrent);
// Assert
torrentsApiMock.Verify(m => m.ControlAsync("torrent-id", "delete", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Delete_CallsUsenetControl_WhenTypeIsNzb()
{
// Arrange
var torrent = new Torrent
{
RdId = "nzb-id",
Type = DownloadType.Nzb
};
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
// Act
await clientMock.Object.Delete(torrent);
// Assert
usenetApiMock.Verify(m => m.ControlAsync("nzb-id", "delete", false, It.IsAny<CancellationToken>()), 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<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String> { 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<CancellationToken>()), 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<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String> { 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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task AddNzbFile_CallsUsenetAddFileAsyncWithName()
{
// Arrange
var bytes = new Byte[] { 1, 2, 3 };
var name = "test-nzb";
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true };
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.AddFileAsync(bytes, -1, name, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<UsenetAddResult> { 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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetDownloadInfos_GeneratesCorrectFakedlLinks_ForIndividualFiles()
{
// Arrange
var files = new List<DebridClientFile>
{
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<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new TorrentInfoResult { Id = 12345 });
_fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).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<DebridClientFile>
{
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<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new TorrentInfoResult { Id = 12345 });
_fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).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<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 6789, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String> { 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<CancellationToken>()), 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<ITorrentsApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 0, true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String> { 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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetDownloadInfos_GeneratesCorrectFakedlLinks_ForUsenet()
{
// Arrange
var files = new List<DebridClientFile>
{
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<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.GetCurrentAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<UsenetInfoResult> { new() { Hash = "nzb-hash", Id = 98765 } });
_fileFilterMock.Setup(m => m.IsDownloadable(torrent, It.IsAny<String>(), It.IsAny<Int64>())).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<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<String> { 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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpdateData_LogsWarning_WhenTorBoxStatusIsUnmapped()
{
// Arrange
var torrent = new Torrent
{
RdId = "test-rd-id",
RdStatus = TorrentStatus.Downloading,
RdName = "test-torrent"
};
var torrentClientTorrent = new DebridClientTorrent
{
Status = "some-unknown-status",
Filename = "test-torrent"
};
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
// Act
await clientMock.Object.UpdateData(torrent, torrentClientTorrent);
// Assert
_loggerMock.Verify(
x => x.Log(
Microsoft.Extensions.Logging.LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unmapped status") && v.ToString()!.Contains("some-unknown-status")),
It.IsAny<Exception>(),
It.Is<Func<It.IsAnyType, Exception?, String>>((v, t) => true)),
Times.Once);
}
}

View file

@ -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 = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\r\n<nzb xmlns=\"http://www.newzbin.com/DTD/2003/nzb\">\r\n <head>\r\n <meta type=\"title\">Test NZB Title</meta>\r\n </head>\r\n</nzb>";
var bytes = Encoding.UTF8.GetBytes(nzbContent);
mocks.TorrentDataMock.Setup(t => t.Add(It.IsAny<String>(),
It.IsAny<String>(),
It.IsAny<String>(),
true,
DownloadType.Nzb,
It.IsAny<Data.Enums.DownloadClient>(),
It.IsAny<Torrent>()))
.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<String>(),
It.IsAny<String>(),
true,
DownloadType.Nzb,
It.IsAny<Data.Enums.DownloadClient>(),
It.IsAny<Torrent>()), 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<String>(),
It.IsAny<String>(),
It.IsAny<String>(),
false,
DownloadType.Nzb,
It.IsAny<Data.Enums.DownloadClient>(),
It.IsAny<Torrent>()))
.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<String>(),
link,
false,
DownloadType.Nzb,
It.IsAny<Data.Enums.DownloadClient>(),
It.IsAny<Torrent>()), Times.Once);
}
}

View file

@ -28,7 +28,13 @@ public class WatchFolderChecker(ILogger<WatchFolderChecker> 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<WatchFolderChecker> 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<WatchFolderChecker> 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<WatchFolderChecker> 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))
{

View file

@ -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<IAllDebridNetClientFactory, AllDebridNetClientFactory>();
services.AddScoped<AllDebridTorrentClient>();
services.AddScoped<AllDebridDebridClient>();
services.AddSingleton<IProcessFactory, ProcessFactory>();
services.AddSingleton<IFileSystem, FileSystem>();
@ -31,12 +31,13 @@ public static class DiConfig
services.AddScoped<Authentication>();
services.AddScoped<IDownloads, Downloads>();
services.AddScoped<Downloads>();
services.AddScoped<PremiumizeTorrentClient>();
services.AddScoped<PremiumizeDebridClient>();
services.AddScoped<QBittorrent>();
services.AddScoped<Sabnzbd>();
services.AddScoped<RemoteService>();
services.AddScoped<RealDebridTorrentClient>();
services.AddScoped<RealDebridDebridClient>();
services.AddScoped<Settings>();
services.AddScoped<TorBoxTorrentClient>();
services.AddScoped<TorBoxDebridClient>();
services.AddScoped<Torrents>();
services.AddScoped<TorrentRunner>();
services.AddScoped<DebridLinkClient>();
@ -46,6 +47,7 @@ public static class DiConfig
services.AddSingleton<IEnricher, Enricher>();
services.AddSingleton<IAuthorizationHandler, AuthSettingHandler>();
services.AddScoped<IAuthorizationHandler, SabnzbdHandler>();
services.AddHostedService<DiskSpaceMonitor>();
services.AddHostedService<ProviderUpdater>();

View file

@ -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<String>();
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<LogEventProperty>();
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);
}
}
}
}

View file

@ -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)

View file

@ -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]}";
}
}

View file

@ -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<SabnzbdRequirement>
{
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);
}
}
}
}
}

View file

@ -25,7 +25,7 @@
<PackageReference Include="Synology.Api.Client" Version="0.3.93" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
<PackageReference Include="TorBox.NET" Version="1.5.0" />
<PackageReference Include="TorBox.NET" Version="1.6.0" />
</ItemGroup>
<ItemGroup>

View file

@ -14,7 +14,7 @@ public class Authentication(SignInManager<IdentityUser> signInManager, UserManag
return result;
}
public async Task<SignInResult> Login(String userName, String password)
public virtual async Task<SignInResult> Login(String userName, String password)
{
if (String.IsNullOrWhiteSpace(userName) || String.IsNullOrWhiteSpace(password))
{

View file

@ -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<AllDebridNetClientFactory> logger
}
}
public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAllDebridNetClientFactory allDebridNetClientFactory, IDownloadableFileFilter fileFilter)
: ITorrentClient
public class AllDebridDebridClient(ILogger<AllDebridDebridClient> logger, IAllDebridNetClientFactory allDebridNetClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
{
private static readonly Int64 SessionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
private static List<TorrentClientTorrent> _cache = [];
private static Int64 _sessionCounter;
private static List<DebridClientTorrent> _cache = [];
private static Int64 _sessionCounter = 0;
public async Task<IList<TorrentClientTorrent>> 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<IList<DebridClientTorrent>> GetDownloads()
{
var results = await allDebridNetClientFactory.GetClient().Magnet.StatusLiveAsync(SessionId, _sessionCounter);
@ -89,7 +113,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAll
return _cache;
}
public async Task<TorrentClientUser> GetUser()
public async Task<DebridClientUser> GetUser()
{
var user = await allDebridNetClientFactory.GetClient().User.GetAsync() ?? throw new("Unable to get user");
@ -100,7 +124,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAll
};
}
public async Task<String> AddMagnet(String magnetLink)
public async Task<String> AddTorrentMagnet(String magnetLink)
{
var result = await allDebridNetClientFactory.GetClient().Magnet.UploadMagnetAsync(magnetLink);
@ -114,7 +138,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAll
return resultId;
}
public async Task<String> AddFile(Byte[] bytes)
public async Task<String> AddTorrentFile(Byte[] bytes)
{
var result = await allDebridNetClientFactory.GetClient().Magnet.UploadFileAsync(bytes);
@ -128,9 +152,19 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAll
return resultId;
}
public Task<IList<TorrentClientAvailableFile>> GetAvailableFiles(String hash)
public Task<String> AddNzbLink(String nzbLink)
{
return Task.FromResult<IList<TorrentClientAvailableFile>>([]);
throw new NotSupportedException();
}
public Task<String> AddNzbFile(Byte[] bytes, String? name)
{
throw new NotSupportedException();
}
public Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)
{
return Task.FromResult<IList<DebridClientAvailableFile>>([]);
}
/// <inheritdoc />
@ -139,12 +173,12 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAll
return Task.FromResult<Int32?>(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<String> Unrestrict(String link)
public async Task<String> Unrestrict(Torrent torrent, String link)
{
var result = await allDebridNetClientFactory.GetClient().Links.DownloadLinkAsync(link);
@ -156,7 +190,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAll
return result.Link;
}
public async Task<Torrent> UpdateData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent)
public async Task<Torrent> UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent)
{
try
{
@ -255,40 +289,13 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> 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<TorrentClientTorrent> GetInfo(String torrentId)
private async Task<DebridClientTorrent> 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<TorrentClientFile> GetFiles(List<File>? files, String parentPath = "")
private static List<DebridClientFile> GetFiles(List<File>? files, String parentPath = "")
{
if (files == null)
{
@ -301,7 +308,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAll
? file.FolderOrFileName
: Path.Combine(parentPath, file.FolderOrFileName);
var result = new List<TorrentClientFile>();
var result = new List<DebridClientFile>();
// If it's a file (has size)
if (file.Size.HasValue)

View file

@ -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<DebridLinkClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : ITorrentClient
public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
{
public async Task<IList<TorrentClientTorrent>> 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<IList<DebridClientTorrent>> GetDownloads()
{
var page = 0;
var results = new List<Torrent>();
@ -35,7 +107,7 @@ public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFacto
return results.Select(Map).ToList();
}
public async Task<TorrentClientUser> GetUser()
public async Task<DebridClientUser> GetUser()
{
var user = await GetClient().Account.Infos();
@ -46,23 +118,33 @@ public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFacto
};
}
public async Task<String> AddMagnet(String magnetLink)
public async Task<String> AddTorrentMagnet(String magnetLink)
{
var result = await GetClient().Seedbox.AddTorrentAsync(magnetLink);
return result.Id ?? "";
}
public async Task<String> AddFile(Byte[] bytes)
public async Task<String> AddTorrentFile(Byte[] bytes)
{
var result = await GetClient().Seedbox.AddTorrentByFileAsync(bytes);
return result.Id ?? "";
}
public Task<IList<TorrentClientAvailableFile>> GetAvailableFiles(String hash)
public Task<String> AddNzbLink(String nzbLink)
{
return Task.FromResult<IList<TorrentClientAvailableFile>>([]);
throw new NotSupportedException();
}
public Task<String> AddNzbFile(Byte[] bytes, String? name)
{
throw new NotSupportedException();
}
public Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)
{
return Task.FromResult<IList<DebridClientAvailableFile>>([]);
}
/// <inheritdoc />
@ -71,17 +153,17 @@ public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFacto
return Task.FromResult<Int32?>(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<String> Unrestrict(String link)
public Task<String> Unrestrict(Data.Models.Data.Torrent torrent, String link)
{
return Task.FromResult(link);
}
public async Task<Data.Models.Data.Torrent> UpdateData(Data.Models.Data.Torrent torrent, TorrentClientTorrent? torrentClientTorrent)
public async Task<Data.Models.Data.Torrent> UpdateData(Data.Models.Data.Torrent torrent, DebridClientTorrent? torrentClientTorrent)
{
try
{
@ -197,79 +279,7 @@ public class DebridLinkClient(ILogger<DebridLinkClient> 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<TorrentClientTorrent> GetInfo(String torrentId)
private async Task<DebridClientTorrent> GetInfo(String torrentId)
{
var result = await GetClient().Seedbox.ListAsync(torrentId);

View file

@ -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<IList<TorrentClientTorrent>> GetTorrents();
Task<TorrentClientUser> GetUser();
Task<String> AddMagnet(String magnetLink);
Task<String> AddFile(Byte[] bytes);
Task<IList<TorrentClientAvailableFile>> GetAvailableFiles(String hash);
Task<IList<DebridClientTorrent>> GetDownloads();
Task<DebridClientUser> GetUser();
Task<String> AddTorrentMagnet(String magnetLink);
Task<String> AddTorrentFile(Byte[] bytes);
Task<String> AddNzbLink(String nzbLink);
Task<String> AddNzbFile(Byte[] bytes, String? name);
Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash);
/// <summary>
/// Tell the debrid provider which files to download.
/// </summary>
@ -20,10 +21,9 @@ public interface ITorrentClient
/// <param name="torrent">The torrent to select files for</param>
/// <returns>Number of files selected</returns>
Task<Int32?> SelectFiles(Torrent torrent);
Task Delete(String torrentId);
Task<String> Unrestrict(String link);
Task<Torrent> UpdateData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent);
Task Delete(Torrent torrent);
Task<String> Unrestrict(Torrent torrent, String link);
Task<Torrent> UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent);
Task<IList<DownloadInfo>?> GetDownloadInfos(Torrent torrent);
/// <summary>

View file

@ -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<PremiumizeTorrentClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : ITorrentClient
public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
{
public async Task<IList<TorrentClientTorrent>> 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<IList<DebridClientTorrent>> GetDownloads()
{
var results = await GetClient().Transfers.ListAsync();
return results.Select(Map).ToList();
}
public async Task<TorrentClientUser> GetUser()
public async Task<DebridClientUser> GetUser()
{
var user = await GetClient().Account.InfoAsync() ?? throw new("Unable to get user");
@ -30,7 +90,7 @@ public class PremiumizeTorrentClient(ILogger<PremiumizeTorrentClient> logger, IH
};
}
public async Task<String> AddMagnet(String magnetLink)
public async Task<String> AddTorrentMagnet(String magnetLink)
{
var result = await GetClient().Transfers.CreateAsync(magnetLink, "");
@ -44,7 +104,7 @@ public class PremiumizeTorrentClient(ILogger<PremiumizeTorrentClient> logger, IH
return resultId;
}
public async Task<String> AddFile(Byte[] bytes)
public async Task<String> AddTorrentFile(Byte[] bytes)
{
var result = await GetClient().Transfers.CreateAsync(bytes, "");
@ -58,9 +118,19 @@ public class PremiumizeTorrentClient(ILogger<PremiumizeTorrentClient> logger, IH
return resultId;
}
public Task<IList<TorrentClientAvailableFile>> GetAvailableFiles(String hash)
public Task<String> AddNzbLink(String nzbLink)
{
return Task.FromResult<IList<TorrentClientAvailableFile>>([]);
throw new NotSupportedException();
}
public Task<String> AddNzbFile(Byte[] bytes, String? name)
{
throw new NotSupportedException();
}
public Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)
{
return Task.FromResult<IList<DebridClientAvailableFile>>([]);
}
/// <inheritdoc />
@ -71,17 +141,17 @@ public class PremiumizeTorrentClient(ILogger<PremiumizeTorrentClient> logger, IH
return Task.FromResult<Int32?>(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<String> Unrestrict(String link)
public Task<String> Unrestrict(Torrent torrent, String link)
{
return Task.FromResult(link);
}
public async Task<Torrent> UpdateData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent)
public async Task<Torrent> UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent)
{
try
{
@ -194,67 +264,7 @@ public class PremiumizeTorrentClient(ILogger<PremiumizeTorrentClient> 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<TorrentClientTorrent> GetInfo(String id)
private async Task<DebridClientTorrent> 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}");

View file

@ -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<RealDebridTorrentClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : ITorrentClient
public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
{
private TimeSpan? _offset;
public async Task<IList<TorrentClientTorrent>> 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<IList<DebridClientTorrent>> GetDownloads()
{
var offset = 0;
var results = new List<Torrent>();
@ -37,7 +115,7 @@ public class RealDebridTorrentClient(ILogger<RealDebridTorrentClient> logger, IH
return results.Select(Map).ToList();
}
public async Task<TorrentClientUser> GetUser()
public async Task<DebridClientUser> GetUser()
{
var user = await GetClient().User.GetAsync();
@ -48,7 +126,7 @@ public class RealDebridTorrentClient(ILogger<RealDebridTorrentClient> logger, IH
};
}
public async Task<String> AddMagnet(String magnetLink)
public async Task<String> AddTorrentMagnet(String magnetLink)
{
var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout));
@ -57,7 +135,7 @@ public class RealDebridTorrentClient(ILogger<RealDebridTorrentClient> logger, IH
return result.Id;
}
public async Task<String> AddFile(Byte[] bytes)
public async Task<String> AddTorrentFile(Byte[] bytes)
{
var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout));
@ -66,15 +144,25 @@ public class RealDebridTorrentClient(ILogger<RealDebridTorrentClient> logger, IH
return result.Id;
}
public Task<IList<TorrentClientAvailableFile>> GetAvailableFiles(String hash)
public Task<String> AddNzbLink(String nzbLink)
{
return Task.FromResult<IList<TorrentClientAvailableFile>>([]);
throw new NotSupportedException();
}
public Task<String> AddNzbFile(Byte[] bytes, String? name)
{
throw new NotSupportedException();
}
public Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)
{
return Task.FromResult<IList<DebridClientAvailableFile>>([]);
}
/// <inheritdoc />
public async Task<Int32?> SelectFiles(Data.Models.Data.Torrent torrent)
{
List<TorrentClientFile> files;
List<DebridClientFile> files;
Log("Seleting files", torrent);
@ -105,12 +193,12 @@ public class RealDebridTorrentClient(ILogger<RealDebridTorrentClient> 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<String> Unrestrict(String link)
public async Task<String> Unrestrict(Data.Models.Data.Torrent torrent, String link)
{
var result = await GetClient().Unrestrict.LinkAsync(link);
@ -122,7 +210,7 @@ public class RealDebridTorrentClient(ILogger<RealDebridTorrentClient> logger, IH
return result.Download;
}
public async Task<Data.Models.Data.Torrent> UpdateData(Data.Models.Data.Torrent torrent, TorrentClientTorrent? torrentClientTorrent)
public async Task<Data.Models.Data.Torrent> UpdateData(Data.Models.Data.Torrent torrent, DebridClientTorrent? torrentClientTorrent)
{
try
{
@ -283,84 +371,6 @@ public class RealDebridTorrentClient(ILogger<RealDebridTorrentClient> 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<RealDebridTorrentClient> logger, IH
return dateTimeOffset?.Subtract(_offset.Value).ToOffset(_offset.Value);
}
private async Task<TorrentClientTorrent> GetInfo(String torrentId)
private async Task<DebridClientTorrent> GetInfo(String torrentId)
{
var result = await GetClient().Torrents.GetInfoAsync(torrentId);

View file

@ -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<TorBoxTorrentClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : ITorrentClient
public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
{
private TimeSpan? _offset;
public async Task<IList<TorrentClientTorrent>> GetTorrents()
protected virtual ITorBoxNetClient GetClient()
{
var torrents = new List<TorrentInfoResult>();
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<TorrentClientUser> GetUser()
protected virtual async Task<IEnumerable<TorrentInfoResult>?> GetCurrentTorrents()
{
return await GetClient().Torrents.GetCurrentAsync(true);
}
protected virtual async Task<IEnumerable<TorrentInfoResult>?> GetQueuedTorrents()
{
return await GetClient().Torrents.GetQueuedAsync(true);
}
protected virtual async Task<IEnumerable<UsenetInfoResult>?> GetCurrentUsenet()
{
return await GetClient().Usenet.GetCurrentAsync(true);
}
protected virtual async Task<IEnumerable<UsenetInfoResult>?> GetQueuedUsenet()
{
return await GetClient().Usenet.GetQueuedAsync(true);
}
protected virtual async Task<Response<List<AvailableTorrent?>>> GetTorrentAvailability(String hash)
{
return await GetClient().Torrents.GetAvailabilityAsync(hash, listFiles: true);
}
protected virtual async Task<Response<List<AvailableUsenet?>>> 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<IList<DebridClientTorrent>> GetDownloads()
{
var results = new List<DebridClientTorrent>();
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<DebridClientUser> GetUser()
{
var user = await GetClient().User.GetAsync(false);
@ -47,7 +195,7 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
};
}
public async Task<String> AddMagnet(String magnetLink)
public async Task<String> AddTorrentMagnet(String magnetLink)
{
var user = await GetClient().User.GetAsync(true);
@ -63,7 +211,7 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
return result.Data!.Hash!;
}
public async Task<String> AddFile(Byte[] bytes)
public async Task<String> AddTorrentFile(Byte[] bytes)
{
var user = await GetClient().User.GetAsync(true);
@ -81,18 +229,42 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
return result.Data!.Hash!;
}
public async Task<IList<TorrentClientAvailableFile>> GetAvailableFiles(String hash)
public async Task<String> 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<String> AddNzbFile(Byte[] bytes, String? name)
{
var result = await GetClient().Usenet.AddFileAsync(bytes, name: name);
return result.Data!.Hash!;
}
public async Task<IList<DebridClientAvailableFile>> 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<TorBoxTorrentClient> logger, IHttpClien
return Task.FromResult<Int32?>(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<String> Unrestrict(String link)
public async Task<String> 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<String> 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<Torrent> UpdateData(Torrent torrent, TorrentClientTorrent? torrentClientTorrent)
public async Task<Torrent> UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent)
{
try
{
@ -135,7 +347,7 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> 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))
{
@ -177,13 +389,17 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
}
else
{
logger.LogTrace("Updating status for {TorrentName} from {OldStatus} to {NewStatus}", torrent.RdName, torrent.RdStatus, rdTorrent.Status);
torrent.RdStatus = rdTorrent.Status switch
{
"allocating" => TorrentStatus.Processing,
"queued" => TorrentStatus.Processing,
"queuedDL" => TorrentStatus.Processing,
"metaDL" => TorrentStatus.Processing,
"checking" => TorrentStatus.Processing,
"checkingResumeData" => TorrentStatus.Processing,
"paused" => TorrentStatus.Downloading,
"pausedDL" => TorrentStatus.Downloading,
"stalledDL" => TorrentStatus.Downloading,
"downloading" => TorrentStatus.Downloading,
"completed" => TorrentStatus.Downloading,
@ -192,8 +408,11 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
"stalled" => TorrentStatus.Downloading,
"stalled (no seeds)" => TorrentStatus.Downloading,
"processing" => TorrentStatus.Downloading,
"checkingDL" => TorrentStatus.Downloading,
"cached" => TorrentStatus.Finished,
_ => TorrentStatus.Error
"error" => TorrentStatus.Error,
_ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("failed") => TorrentStatus.Error,
_ => LogUnmappedStatus(rdTorrent.Status, torrent)
};
}
}
@ -214,7 +433,29 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
public async Task<IList<DownloadInfo>?> 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 +466,7 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
[
new()
{
RestrictedLink = $"https://torbox.app/fakedl/{torrentId?.Id}/zip",
RestrictedLink = $"https://torbox.app/fakedl/{id}/zip",
FileName = $"{torrent.RdName}.zip"
}
];
@ -235,7 +476,7 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> 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 +491,6 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> 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 +502,39 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
return dateTimeOffset?.Subtract(_offset.Value).ToOffset(_offset.Value);
}
private async Task<TorrentClientTorrent> GetInfo(String torrentHash)
private async Task<DebridClientTorrent?> 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 +551,7 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
Directory.Move(dir, destDir);
}
if (!extractPath.Contains(_torrent.RdName!))
if (!extractPath.Contains(torrent.RdName!))
{
Directory.Delete(innerFolder, true);
}
@ -384,6 +562,16 @@ public class TorBoxTorrentClient(ILogger<TorBoxTorrentClient> logger, IHttpClien
}
}
private TorrentStatus LogUnmappedStatus(String? status, Torrent torrent)
{
if (!String.IsNullOrWhiteSpace(status))
{
logger.LogInformation("TorBoxDebridClient encountered an unmapped status: {Status} for torrent {TorrentName}", status, torrent.RdName);
}
return torrent.RdStatus ?? TorrentStatus.Processing;
}
private void Log(String message, Torrent? torrent = null)
{
if (torrent != null)

View file

@ -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)

View file

@ -19,7 +19,6 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
public async Task AuthLogout()
{
logger.LogDebug("Auth logout");
await authentication.Logout();
}
@ -191,6 +190,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
var results = new List<TorrentInfo>();
var allTorrents = await torrents.Get();
allTorrents = allTorrents.Where(m => m.Type == DownloadType.Torrent).ToList();
var prio = 0;
@ -223,29 +223,28 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
var speed = torrent.RdSpeed ?? 0;
var bytesDone = (Int64)(bytesTotal * rdProgress);
Double downloadProgress = 0;
Double progress;
if (torrent.Downloads is { Count: > 0 })
{
var dlBytesDone = torrent.Downloads.Sum(m => m.BytesDone);
var dlBytesTotal = torrent.Downloads.Sum(m => m.BytesTotal);
speed = (Int32)torrent.Downloads.Average(m => m.Speed);
downloadProgress = bytesTotal > 0 ? Math.Clamp((Double)dlBytesDone / dlBytesTotal, 0.0, 1.0) : 0;
var dlStats = torrent.Downloads.Select(m => torrents.GetDownloadStats(m.DownloadId)).ToList();
var dlBytesDone = dlStats.Sum(m => m.BytesDone);
var dlBytesTotal = dlStats.Sum(m => m.BytesTotal);
speed = (Int32)(dlStats.Any() ? dlStats.Average(m => m.Speed) : 0);
var downloadProgress = dlBytesTotal > 0 ? Math.Clamp((Double)dlBytesDone / dlBytesTotal, 0.0, 1.0) : 0;
progress = (rdProgress + downloadProgress) / 2.0;
}
else
{
progress = rdProgress;
}
var progress = (rdProgress + downloadProgress) / 2.0;
var remaining = TimeSpan.Zero;
var bytesRemaining = bytesTotal - bytesDone;
if (speed > 0 && bytesRemaining > 0)
if (progress > 0 && progress < 1.0)
{
remaining = TimeSpan.FromSeconds(bytesRemaining / (Double)speed);
// In case there is clock skew
if (remaining < TimeSpan.Zero)
{
remaining = TimeSpan.Zero;
}
var startTime = torrent.Retry > torrent.Added ? torrent.Retry.Value : torrent.Added;
var elapsed = DateTimeOffset.UtcNow - startTime;
var totalEstimatedTime = TimeSpan.FromTicks((Int64)(elapsed.Ticks / progress));
remaining = totalEstimatedTime - elapsed;
}
var result = new TorrentInfo
@ -321,7 +320,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
var torrent = await torrents.GetByHash(hash);
if (torrent == null)
if (torrent == null || torrent.Type != DownloadType.Torrent)
{
return null;
}
@ -345,7 +344,7 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
var torrent = await torrents.GetByHash(hash);
if (torrent == null)
if (torrent == null || torrent.Type != DownloadType.Torrent)
{
return null;
}
@ -361,9 +360,10 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
if (torrent.Downloads.Count > 0)
{
bytesDone = torrent.Downloads.Sum(m => m.BytesDone);
bytesTotal = torrent.Downloads.Sum(m => m.BytesTotal);
speed = (Int32)torrent.Downloads.Average(m => m.Speed);
var dlStats = torrent.Downloads.Select(m => torrents.GetDownloadStats(m.DownloadId)).ToList();
bytesDone = dlStats.Sum(m => m.BytesDone);
bytesTotal = dlStats.Sum(m => m.BytesTotal);
speed = (Int32)(dlStats.Any() ? dlStats.Average(m => m.Speed) : 0);
}
var result = new TorrentProperties
@ -419,7 +419,7 @@ public class QBittorrent(ILogger<QBittorrent> 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<QBittorrent> 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<QBittorrent> 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<QBittorrent> 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<QBittorrent> logger, Settings settings, Authent
{
var torrent = await torrents.GetByHash(hash);
if (torrent == null)
if (torrent == null || torrent.Type != DownloadType.Torrent)
{
return;
}

View file

@ -10,67 +10,65 @@ public class RemoteService(IHubContext<RdtHub> 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

View file

@ -0,0 +1,225 @@
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<Sabnzbd> logger, Torrents torrents, AppSettings appSettings)
{
public virtual async Task<SabnzbdQueue> 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) =>
{
var rdProgress = Math.Clamp(t.RdProgress ?? 0.0, 0.0, 100.0) / 100.0;
Double progress;
var dlStats = t.Downloads.Select(m => torrents.GetDownloadStats(m.DownloadId)).ToList();
if (dlStats.Count > 0)
{
var bytesDone = dlStats.Sum(m => m.BytesDone);
var bytesTotal = dlStats.Sum(m => m.BytesTotal);
var downloadProgress = bytesTotal > 0 ? Math.Clamp((Double)bytesDone / bytesTotal, 0.0, 1.0) : 0;
progress = (rdProgress + downloadProgress) / 2.0;
}
else
{
progress = rdProgress;
}
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(dlStats.Sum(d => d.BytesTotal)),
SizeLeft = FileSizeHelper.FormatSize(dlStats.Sum(d => d.BytesTotal - d.BytesDone)),
Percentage = (progress * 100.0).ToString("0"),
Status = t.RdStatus switch
{
TorrentStatus.Queued => "Queued",
TorrentStatus.Processing => "Downloading",
TorrentStatus.WaitingForFileSelection => "Downloading",
TorrentStatus.Downloading => "Downloading",
TorrentStatus.Uploading => "Downloading",
TorrentStatus.Finished => "Completed",
TorrentStatus.Error => "Failed",
_ => "Downloading"
},
Category = t.Category ?? "*",
Priority = "Normal",
TimeLeft = timeLeft
};
}).ToList()
};
return queue;
}
public virtual async Task<SabnzbdHistory> 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<String> 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<String> 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<String> 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;
}
}

View file

@ -23,6 +23,21 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
public static Boolean IsPausedForLowDiskSpace { get; set; }
public static (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetStats(Guid downloadId)
{
if (ActiveDownloadClients.TryGetValue(downloadId, out var downloadClient))
{
return (downloadClient.Speed, downloadClient.BytesTotal, downloadClient.BytesDone);
}
if (ActiveUnpackClients.TryGetValue(downloadId, out var unpackClient))
{
return (0, 100, unpackClient.Progess);
}
return (0, 0, 0);
}
public async Task Initialize()
{
Log("Initializing TorrentRunner");
@ -623,9 +638,9 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
Log($"Torrent reported an error: {torrent.RdStatusRaw}", torrent);
Log($"Torrent retry count {torrent.RetryCount}/{torrent.TorrentRetryAttempts}", torrent);
Log($"Received RealDebrid error: {torrent.RdStatusRaw}, not processing further", torrent);
Log($"Received provider error: {torrent.RdStatusRaw}, not processing further", torrent);
await torrents.UpdateComplete(torrent.TorrentId, $"Received RealDebrid error: {torrent.RdStatusRaw}.", DateTimeOffset.UtcNow, true);
await torrents.UpdateComplete(torrent.TorrentId, $"Debrid error: {torrent.RdStatusRaw}.", DateTimeOffset.UtcNow, true);
continue;
}
@ -665,8 +680,8 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
var completePerc = 0;
var totalDownloadBytes = torrent.Downloads.Sum(m => m.BytesTotal);
var totalDoneBytes = torrent.Downloads.Sum(m => m.BytesDone);
var totalDownloadBytes = torrent.Downloads.Sum(m => GetStats(m.DownloadId).BytesTotal);
var totalDoneBytes = torrent.Downloads.Sum(m => GetStats(m.DownloadId).BytesDone);
if (totalDownloadBytes > 0)
{
@ -697,7 +712,7 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
catch (Exception ex)
{
logger.LogError(ex.Message, "Torrent processing result in an unexpected exception: {Message}", ex.Message);
await torrents.UpdateComplete(torrent.TorrentId, ex.Message, DateTimeOffset.UtcNow, true);
await torrents.UpdateComplete(torrent.TorrentId, $"Runner error: {ex.Message}", DateTimeOffset.UtcNow, true);
}
}

View file

@ -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,51 +40,37 @@ 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<IList<Torrent>> Get()
private static readonly SemaphoreSlim TorrentResetLock = new(1, 1);
public virtual (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetDownloadStats(Guid downloadId)
{
return TorrentRunner.GetStats(downloadId);
}
public virtual async Task<IList<Torrent>> Get()
{
var torrents = await torrentData.Get();
foreach (var torrent in torrents)
{
foreach (var download in torrent.Downloads)
{
if (TorrentRunner.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient))
{
download.Speed = downloadClient.Speed;
download.BytesTotal = downloadClient.BytesTotal;
download.BytesDone = downloadClient.BytesDone;
}
if (TorrentRunner.ActiveUnpackClients.TryGetValue(download.DownloadId, out var unpackClient))
{
download.BytesTotal = 100;
download.BytesDone = unpackClient.Progess;
}
}
}
return torrents;
}
public async Task<Torrent?> GetByHash(String hash)
public virtual async Task<Torrent?> GetByHash(String hash)
{
var torrent = await torrentData.GetByHash(hash);
@ -108,7 +96,83 @@ public class Torrents(
await torrentData.UpdateCategory(torrent.TorrentId, category);
}
public async Task<Torrent> AddMagnetToDebridQueue(String magnetLink, Torrent torrent)
public virtual async Task<Torrent> 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<Torrent> 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<Torrent> AddMagnetToDebridQueue(String magnetLink, Torrent torrent)
{
var enriched = await enricher.EnrichMagnetLink(magnetLink);
MagnetLink magnet;
@ -155,22 +219,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<Torrent> AddFileToDebridQueue(Byte[] bytes, Torrent torrent)
public virtual async Task<Torrent> 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 +292,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 +374,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 +399,9 @@ public class Torrents(
}
}
public async Task<IList<TorrentClientAvailableFile>> GetAvailableFiles(String hash)
public async Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)
{
var result = await TorrentClient.GetAvailableFiles(hash);
var result = await DebridClient.GetAvailableFiles(hash);
return result;
}
@ -335,7 +415,7 @@ public class Torrents(
return;
}
var selected = await TorrentClient.SelectFiles(torrent);
var selected = await DebridClient.SelectFiles(torrent);
if (selected == 0)
{
@ -352,7 +432,7 @@ public class Torrents(
return;
}
var downloadInfos = await TorrentClient.GetDownloadInfos(torrent);
var downloadInfos = await DebridClient.GetDownloadInfos(torrent);
if (downloadInfos == null)
{
@ -394,7 +474,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 +540,7 @@ public class Torrents(
try
{
await TorrentClient.Delete(torrent.RdId);
await DebridClient.Delete(torrent);
}
catch
{
@ -509,25 +589,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;
}
/// <summary>
/// To be called only when <see cref="Data.Models.Data.Download" />.<see cref="Data.Models.Data.Download.FileName" />
/// is not set by
/// <see cref="ITorrentClient.GetDownloadInfos" />
/// </summary>
/// <inheritdoc />
public async Task<String> 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 +612,7 @@ public class Torrents(
public async Task<Profile> GetProfile()
{
var user = await TorrentClient.GetUser();
var user = await DebridClient.GetUser();
var profile = new Profile
{
@ -560,7 +636,7 @@ public class Torrents(
try
{
var rdTorrents = await TorrentClient.GetTorrents();
var rdTorrents = await DebridClient.GetDownloads();
foreach (var rdTorrent in rdTorrents)
{
@ -573,8 +649,7 @@ public class Torrents(
{
Category = Settings.Get.Provider.Default.Category,
DownloadClient = Settings.Get.DownloadClient.Client,
DownloadAction =
Settings.Get.Provider.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
DownloadAction = Settings.Get.Provider.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll,
HostDownloadAction = Settings.Get.Provider.Default.HostDownloadAction,
FinishedActionDelay = Settings.Get.Provider.Default.FinishedActionDelay,
FinishedAction = Settings.Get.Provider.Default.FinishedAction,
@ -594,7 +669,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 +745,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);
@ -775,22 +866,6 @@ public class Torrents(
await UpdateTorrentClientData(torrent);
foreach (var download in torrent.Downloads)
{
if (TorrentRunner.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient))
{
download.Speed = downloadClient.Speed;
download.BytesTotal = downloadClient.BytesTotal;
download.BytesDone = downloadClient.BytesDone;
}
if (TorrentRunner.ActiveUnpackClients.TryGetValue(download.DownloadId, out var unpackClient))
{
download.BytesTotal = 100;
download.BytesDone = unpackClient.Progess;
}
}
return torrent;
}
@ -809,6 +884,7 @@ public class Torrents(
private async Task<Torrent> AddQueued(String infoHash,
String fileOrMagnetContents,
Boolean isFile,
DownloadType downloadType,
Torrent torrent)
{
var existingTorrent = await torrentData.GetByHash(infoHash);
@ -822,6 +898,7 @@ public class Torrents(
infoHash,
fileOrMagnetContents,
isFile,
downloadType,
torrent.DownloadClient,
torrent);
@ -931,13 +1008,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 +1053,27 @@ 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();
}
}

View file

@ -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)

View file

@ -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<Sabnzbd> _sabnzbdMock;
private readonly Mock<Authentication> _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<Torrents>(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
var sabnzbdLoggerMock = new Mock<ILogger<Sabnzbd>>();
_sabnzbdMock = new Mock<Sabnzbd>(sabnzbdLoggerMock.Object, torrentsMock.Object, null!);
var loggerMock = new Mock<ILogger<SabnzbdController>>();
_authenticationMock = new Mock<Authentication>(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<OkObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(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<OkObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(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<OkObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(okResult.Value);
Assert.NotNull(response.Config);
Assert.Equal("6500", response.Config.Misc.Port);
}
[Fact]
public void GetCategories_ReturnsOk()
{
// Arrange
var categories = new List<String> { "*", "Default" };
_sabnzbdMock.Setup(s => s.GetCategories()).Returns(categories);
// Act
var result = _controller.GetCats();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(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<BadRequestObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(badRequestResult.Value);
Assert.Equal("No mode specified", response.Error);
}
[Fact]
public void Get_UnknownMode_ReturnsNotFound()
{
// Act
var result = _controller.Get("unknown");
// Assert
Assert.IsType<NotFoundObjectResult>(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<String, Microsoft.Extensions.Primitives.StringValues>
{
{ "mode", "unknown_form" }
});
_controller.ControllerContext.HttpContext = httpContext;
// Act
var result = _controller.Get(null);
// Assert
Assert.IsType<NotFoundObjectResult>(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<IFormFile>();
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<Stream>(), It.IsAny<CancellationToken>()))
.Callback<Stream, CancellationToken>((s, _) => ms.CopyTo(s))
.Returns(Task.CompletedTask);
httpContext.Request.ContentType = "multipart/form-data; boundary=something";
httpContext.Request.Form = new FormCollection(new Dictionary<String, Microsoft.Extensions.Primitives.StringValues>(), new FormFileCollection { fileMock.Object });
_controller.ControllerContext.HttpContext = httpContext;
_sabnzbdMock.Setup(s => s.AddFile(It.IsAny<Byte[]>(), fileName, "radarr", -100)).ReturnsAsync("nzo_id_123");
// Act
var result = await _controller.AddFile();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var response = Assert.IsType<SabnzbdResponse>(okResult.Value);
Assert.True(response.Status);
Assert.Contains("nzo_id_123", response.NzoIds!);
_sabnzbdMock.Verify(s => s.AddFile(It.IsAny<Byte[]>(), fileName, "radarr", -100), Times.Once);
}
}

View file

@ -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<Authentication> _authenticationMock;
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
private readonly SabnzbdHandler _handler;
public SabnzbdHandlerTest()
{
_authenticationMock = new Mock<Authentication>(null!, null!, null!);
_httpContextAccessorMock = new Mock<IHttpContextAccessor>();
_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<String>(), It.IsAny<String>()), 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);
}
}

View file

@ -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<Torrents> _torrentsMock;
private readonly Mock<ILogger<TorrentsController>> _loggerMock;
private readonly TorrentsController _controller;
public TorrentsControllerNzbTest()
{
_torrentsMock = new Mock<Torrents>(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
_loggerMock = new Mock<ILogger<TorrentsController>>();
_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<OkResult>(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<BadRequestResult>(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<BadRequestObjectResult>(result);
Assert.Equal("Invalid nzb link", badRequest.Value);
}
[Fact]
public async Task UploadNzbFile_ValidRequest_ReturnsOk()
{
// Arrange
var fileMock = new Mock<IFormFile>();
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<OkResult>(result);
_torrentsMock.Verify(t => t.AddNzbFileToDebridQueue(It.IsAny<Byte[]>(), 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<BadRequestObjectResult>(result);
Assert.Equal("Invalid nzb file", badRequest.Value);
}
}

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<AssemblyName>RdtClient.Web.Test</AssemblyName>
<RootNamespace>RdtClient.Web.Test</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RdtClient.Web\RdtClient.Web.csproj" />
<ProjectReference Include="..\RdtClient.Service\RdtClient.Service.csproj" />
<ProjectReference Include="..\RdtClient.Data\RdtClient.Data.csproj" />
</ItemGroup>
</Project>

View file

@ -3,10 +3,11 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "3.1.3",
"version": "9.0.9",
"commands": [
"dotnet-ef"
]
],
"rollForward": false
}
}
}

View file

@ -13,6 +13,7 @@ namespace RdtClient.Web.Controllers;
/// </summary>
[ApiController]
[Route("api/v2")]
[Route("qbittorrent/api/v2")]
public class QBittorrentController(ILogger<QBittorrentController> logger, QBittorrent qBittorrent) : Controller
{
[AllowAnonymous]

View file

@ -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<SabnzbdController> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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;
}
}

View file

@ -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);
}
}

View file

@ -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<TorrentsController> 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<TorrentsController> 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<TorrentsController> 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);
@ -171,7 +170,6 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
public ActionResult<DiskSpaceStatus?> GetDiskSpaceStatus()
{
var status = DiskSpaceMonitor.GetCurrentStatus();
return Ok(status);
}
@ -245,6 +243,68 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
return Ok();
}
[HttpPost]
[Route("UploadNzbFile")]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> CheckFiles([FromForm] IFormFile? file)
@ -355,7 +415,7 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
var includeError = "";
var excludeError = "";
IList<TorrentClientAvailableFile> availableFiles;
IList<DebridClientAvailableFile> availableFiles;
if (!String.IsNullOrWhiteSpace(request.MagnetLink))
{
@ -382,7 +442,7 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
return BadRequest();
}
var selectedFiles = new List<TorrentClientAvailableFile>();
var selectedFiles = new List<DebridClientAvailableFile>();
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; }

View file

@ -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<CredentialRedactorEnricher>()
.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<IdentityUser, IdentityRole>(options =>
{

View file

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