Mem and CPU perf fixes

This commit is contained in:
omgbeez 2026-04-21 22:19:06 -04:00 committed by Clifford Roche
parent 145ea9a8ff
commit 5c71ecc211
16 changed files with 839 additions and 503 deletions

View file

@ -1,4 +1,5 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { TorrentService } from 'src/app/torrent.service'; import { TorrentService } from 'src/app/torrent.service';
import { DownloadType, Torrent, TorrentFileAvailability } from '../models/torrent.model'; import { DownloadType, Torrent, TorrentFileAvailability } from '../models/torrent.model';
@ -15,6 +16,7 @@ import { NgClass } from '@angular/common';
standalone: true, standalone: true,
}) })
export class AddNewTorrentComponent implements OnInit { export class AddNewTorrentComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private router = inject(Router); private router = inject(Router);
private torrentService = inject(TorrentService); private torrentService = inject(TorrentService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
@ -29,8 +31,9 @@ export class AddNewTorrentComponent implements OnInit {
public provider: string; public provider: string;
public downloadClient: number; public downloadClient: number;
public category: string; private _category = '';
public categories: string[] = []; public categories: string[] = [];
public filteredCategories: string[] = [];
public categoryDropdownOpen = false; public categoryDropdownOpen = false;
public hostDownloadAction: number = 0; public hostDownloadAction: number = 0;
public downloadAction: number = 0; public downloadAction: number = 0;
@ -56,10 +59,19 @@ export class AddNewTorrentComponent implements OnInit {
public excludeRegexError: string; public excludeRegexError: string;
public regexSelected: TorrentFileAvailability[]; public regexSelected: TorrentFileAvailability[];
private selectedFile: File; private selectedFile: File | null = null;
public get category(): string {
return this._category;
}
public set category(value: string) {
this._category = value;
this.updateFilteredCategories();
}
ngOnInit(): void { ngOnInit(): void {
this.activatedRoute.queryParams.subscribe((params) => { this.activatedRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
if (params['type'] === 'nzb') { if (params['type'] === 'nzb') {
this.type = 'nzb'; this.type = 'nzb';
} else if (params['type'] === 'torrent') { } else if (params['type'] === 'torrent') {
@ -75,45 +87,55 @@ export class AddNewTorrentComponent implements OnInit {
this.type = 'nzb'; this.type = 'nzb';
} }
}); });
this.settingsService.get().subscribe((settings) => {
const providerSetting = settings.find((m) => m.key === 'Provider:Provider');
this.provider = providerSetting.enumValues[providerSetting.value as number];
this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.value as number;
this.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string; this.settingsService
const categoriesSetting = settings.find((m) => m.key === 'General:Categories')?.value as string; .get()
this.categories = (categoriesSetting ?? '') .pipe(takeUntilDestroyed(this.destroyRef))
.split(',') .subscribe((settings) => {
.map((c) => c.trim()) const providerSetting = settings.find((m) => m.key === 'Provider:Provider');
.filter((c) => c.length > 0) this.provider = providerSetting.enumValues[providerSetting.value as number];
.filter((c, i, arr) => arr.findIndex((a) => a.toLowerCase() === c.toLowerCase()) === i); this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.value as number;
const matchedCategory = this.categories.find((c) => c.toLowerCase() === (this.category ?? '').toLowerCase());
if (matchedCategory) {
this.category = matchedCategory;
}
this.hostDownloadAction = this.downloadAction = settings.find((m) => m.key === 'Gui:Default:HostDownloadAction')
?.value as number;
this.downloadAction =
settings.find((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.finishedAction = settings.find((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.finishedActionDelay = settings.find((m) => m.key == 'Gui:Default:FinishedActionDelay')?.value as number;
this.downloadMinSize = settings.find((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
this.includeRegex = settings.find((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
this.excludeRegex = settings.find((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
this.torrentRetryAttempts = settings.find((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
this.downloadRetryAttempts = settings.find((m) => m.key === 'Gui:Default:DownloadRetryAttempts')?.value as number;
this.torrentDeleteOnError = settings.find((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
this.torrentLifetime = settings.find((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
this.priority = settings.find((m) => m.key === 'Gui:Default:Priority')?.value as number;
this.setFinishAction(); this.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string;
}); const categoriesSetting = settings.find((m) => m.key === 'General:Categories')?.value as string;
this.categories = (categoriesSetting ?? '')
.split(',')
.map((c) => c.trim())
.filter((c) => c.length > 0)
.filter((c, i, arr) => arr.findIndex((a) => a.toLowerCase() === c.toLowerCase()) === i);
const matchedCategory = this.categories.find((c) => c.toLowerCase() === (this.category ?? '').toLowerCase());
if (matchedCategory) {
this.category = matchedCategory;
} else {
this.updateFilteredCategories();
}
this.hostDownloadAction = this.downloadAction = settings.find((m) => m.key === 'Gui:Default:HostDownloadAction')
?.value as number;
this.downloadAction =
settings.find((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.finishedAction = settings.find((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.finishedActionDelay = settings.find((m) => m.key == 'Gui:Default:FinishedActionDelay')?.value as number;
this.downloadMinSize = settings.find((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
this.includeRegex = settings.find((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
this.excludeRegex = settings.find((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
this.torrentRetryAttempts = settings.find((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
this.downloadRetryAttempts = settings.find((m) => m.key === 'Gui:Default:DownloadRetryAttempts')?.value as number;
this.torrentDeleteOnError = settings.find((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
this.torrentLifetime = settings.find((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
this.priority = settings.find((m) => m.key === 'Gui:Default:Priority')?.value as number;
this.setFinishAction();
});
} }
get filteredCategories(): string[] { private updateFilteredCategories(): void {
if (!this.category) return this.categories; if (!this.category) {
this.filteredCategories = this.categories;
return;
}
const search = this.category.toLowerCase(); const search = this.category.toLowerCase();
return this.categories.filter((c) => c.toLowerCase().includes(search)); this.filteredCategories = this.categories.filter((value) => value.toLowerCase().includes(search));
} }
public selectCategory(cat: string): void { public selectCategory(cat: string): void {
@ -144,7 +166,7 @@ export class AddNewTorrentComponent implements OnInit {
public pickFile(evt: Event): void { public pickFile(evt: Event): void {
const files = (evt.target as HTMLInputElement).files; const files = (evt.target as HTMLInputElement).files;
if (files.length === 0) { if (files == null || files.length === 0) {
return; return;
} }

View file

@ -44,9 +44,12 @@ export class Torrent {
public rdSpeed: number; public rdSpeed: number;
public rdSeeders: number; public rdSeeders: number;
public rdFiles: string; public rdFiles: string;
public statusText?: string;
public filesCount?: number;
public downloadsCount?: number;
public files: TorrentFile[]; public files?: TorrentFile[];
public downloads: Download[]; public downloads?: Download[];
} }
export class TorrentFile { export class TorrentFile {

View file

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

View file

@ -1,20 +1,78 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
export type SortDirection = 'asc' | 'desc';
export function getSortFieldValue(item: unknown, field: string): unknown {
if (item == null || !field) {
return undefined;
}
return field.split('.').reduce<unknown>((value, key) => {
if (value == null || typeof value !== 'object') {
return undefined;
}
return (value as Record<string, unknown>)[key];
}, item);
}
function compareSortValues(left: unknown, right: unknown): number {
if (left === right) {
return 0;
}
if (left == null) {
return 1;
}
if (right == null) {
return -1;
}
if (typeof left === 'string' && typeof right === 'string') {
return left.localeCompare(right, undefined, { sensitivity: 'base' });
}
if (left instanceof Date && right instanceof Date) {
return left.getTime() - right.getTime();
}
if (typeof left === 'boolean' && typeof right === 'boolean') {
return Number(left) - Number(right);
}
const comparableLeft = left as string | number | bigint;
const comparableRight = right as string | number | bigint;
if (comparableLeft < comparableRight) {
return -1;
}
if (comparableLeft > comparableRight) {
return 1;
}
return 0;
}
export function sortItems<T>(
array: readonly T[],
field: string,
order: SortDirection = 'asc',
accessor: (item: T, field: string) => unknown = getSortFieldValue,
): T[] {
if (!Array.isArray(array)) {
return [];
}
const direction = order === 'asc' ? 1 : -1;
return [...array].sort((left, right) => compareSortValues(accessor(left, field), accessor(right, field)) * direction);
}
@Pipe({ name: 'sort' }) @Pipe({ name: 'sort' })
export class SortPipe implements PipeTransform { export class SortPipe implements PipeTransform {
transform(array: any[], field: string, order: 'asc' | 'desc' = 'asc'): any[] { transform(array: unknown[], field: string, order: SortDirection = 'asc'): unknown[] {
if (!Array.isArray(array)) { return sortItems(array, field, order);
return [];
}
const sortedArray = array.sort((a, b) => {
if (a[field] < b[field]) {
return -1;
} else if (a[field] > b[field]) {
return 1;
} else {
return 0;
}
});
return order === 'asc' ? sortedArray : sortedArray.reverse();
} }
} }

View file

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

View file

@ -47,14 +47,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (torrent of torrents | sort: sortProperty : sortDirection; track torrent.torrentId) { @for (torrent of sortedTorrents; track torrent.torrentId) {
<tr> <tr>
<td> <td>
<input <input type="checkbox" (click)="toggleSelect(torrent.torrentId)" [checked]="isSelected(torrent.torrentId)" />
type="checkbox"
(click)="toggleSelect(torrent.torrentId)"
[checked]="selectedTorrents.includes(torrent.torrentId)"
/>
</td> </td>
<td (click)="openTorrent(torrent.torrentId)" class="break-all"> <td (click)="openTorrent(torrent.torrentId)" class="break-all">
{{ torrent.rdName }} {{ torrent.rdName }}
@ -69,10 +65,10 @@
{{ torrent.rdSeeders }} {{ torrent.rdSeeders }}
</td> </td>
<td> <td>
{{ torrent.files.length | number }} {{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }}
</td> </td>
<td> <td>
{{ torrent.downloads.length | number }} {{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}
</td> </td>
<td> <td>
{{ torrent.rdSize | filesize }} {{ torrent.rdSize | filesize }}
@ -81,7 +77,7 @@
{{ torrent.added | date: "medium" }} {{ torrent.added | date: "medium" }}
</td> </td>
<td> <td>
{{ torrent | status }} {{ torrent.statusText ?? (torrent | status) }}
</td> </td>
</tr> </tr>
} }
@ -89,19 +85,19 @@
</table> </table>
} @else { } @else {
<div class="mobile-cards"> <div class="mobile-cards">
@for (torrent of torrents | sort: sortProperty : sortDirection; track torrent.torrentId) { @for (torrent of sortedTorrents; track torrent.torrentId) {
<div class="box mobile-card" [class.is-selected]="selectedTorrents.includes(torrent.torrentId)"> <div class="box mobile-card" [class.is-selected]="isSelected(torrent.torrentId)">
<div class="mobile-card-checkbox"> <div class="mobile-card-checkbox">
<input <input
type="checkbox" type="checkbox"
(click)="toggleSelect(torrent.torrentId)" (click)="toggleSelect(torrent.torrentId)"
[checked]="selectedTorrents.includes(torrent.torrentId)" [checked]="isSelected(torrent.torrentId)"
/> />
</div> </div>
<div class="mobile-card-body" (click)="openTorrent(torrent.torrentId)"> <div class="mobile-card-body" (click)="openTorrent(torrent.torrentId)">
<p class="mobile-card-name">{{ torrent.rdName }}</p> <p class="mobile-card-name">{{ torrent.rdName }}</p>
<div class="tags mb-2"> <div class="tags mb-2">
<span class="tag is-info is-light">{{ torrent | status }}</span> <span class="tag is-info is-light">{{ torrent.statusText ?? (torrent | status) }}</span>
@if (torrent.category) { @if (torrent.category) {
<span class="tag is-light">{{ torrent.category }}</span> <span class="tag is-light">{{ torrent.category }}</span>
} }
@ -112,10 +108,10 @@
> >
<span class="mobile-card-detail"><span class="detail-label">Seeders</span> {{ torrent.rdSeeders }}</span> <span class="mobile-card-detail"><span class="detail-label">Seeders</span> {{ torrent.rdSeeders }}</span>
<span class="mobile-card-detail" <span class="mobile-card-detail"
><span class="detail-label">Files</span> {{ torrent.files.length | number }}</span ><span class="detail-label">Files</span> {{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }}</span
> >
<span class="mobile-card-detail" <span class="mobile-card-detail"
><span class="detail-label">Downloads</span> {{ torrent.downloads.length | number }}</span ><span class="detail-label">Downloads</span> {{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}</span
> >
</div> </div>
</div> </div>

View file

@ -1,4 +1,5 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Torrent } from '../models/torrent.model'; import { Torrent } from '../models/torrent.model';
import { DiskSpaceStatus } from '../models/disk-space-status.model'; import { DiskSpaceStatus } from '../models/disk-space-status.model';
@ -7,26 +8,29 @@ import { TorrentService } from '../torrent.service';
import { forkJoin, Observable } from 'rxjs'; import { forkJoin, Observable } from 'rxjs';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgClass, DecimalPipe, DatePipe } from '@angular/common'; import { NgClass, DecimalPipe, DatePipe } from '@angular/common';
import { TorrentStatusPipe } from '../torrent-status.pipe'; import { getTorrentStatus, TorrentStatusPipe } from '../torrent-status.pipe';
import { SortPipe } from '../sort.pipe'; import { SortDirection, getSortFieldValue, sortItems } from '../sort.pipe';
import { FileSizePipe } from '../filesize.pipe'; import { FileSizePipe } from '../filesize.pipe';
@Component({ @Component({
selector: 'app-torrent-table', selector: 'app-torrent-table',
templateUrl: './torrent-table.component.html', templateUrl: './torrent-table.component.html',
styleUrls: ['./torrent-table.component.scss'], styleUrls: ['./torrent-table.component.scss'],
imports: [FormsModule, NgClass, DecimalPipe, DatePipe, TorrentStatusPipe, SortPipe, FileSizePipe], imports: [FormsModule, NgClass, DecimalPipe, DatePipe, TorrentStatusPipe, FileSizePipe],
standalone: true, standalone: true,
}) })
export class TorrentTableComponent implements OnInit, OnDestroy { export class TorrentTableComponent implements OnInit, OnDestroy {
private destroyRef = inject(DestroyRef);
private router = inject(Router); private router = inject(Router);
private torrentService = inject(TorrentService); private torrentService = inject(TorrentService);
private selectedTorrentIds = new Set<string>();
public torrents: Torrent[] = []; public torrents: Torrent[] = [];
public sortedTorrents: Torrent[] = [];
public selectedTorrents: string[] = []; public selectedTorrents: string[] = [];
public error: string; public error: string;
public sortProperty = 'added'; public sortProperty = 'added';
public sortDirection: 'asc' | 'desc' = 'desc'; public sortDirection: SortDirection = 'desc';
public isDeleteModalActive: boolean; public isDeleteModalActive: boolean;
public deleteError: string; public deleteError: string;
@ -82,38 +86,47 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
// Ignore storage errors (e.g., disabled storage) // Ignore storage errors (e.g., disabled storage)
} }
this.torrentService.getDiskSpaceStatus().subscribe({ this.torrentService
next: (status) => { .getDiskSpaceStatus()
this.diskSpaceStatus = status; .pipe(takeUntilDestroyed(this.destroyRef))
}, .subscribe({
}); next: (status) => {
this.diskSpaceStatus = status;
},
});
this.torrentService.diskSpaceStatus$.subscribe((status) => { this.torrentService.diskSpaceStatus$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((status) => {
this.diskSpaceStatus = status; this.diskSpaceStatus = status;
}); });
this.torrentService.getRateLimitStatus().subscribe({ this.torrentService
next: (status) => { .getRateLimitStatus()
this.rateLimitStatus = status; .pipe(takeUntilDestroyed(this.destroyRef))
}, .subscribe({
}); next: (status) => {
this.rateLimitStatus = status;
},
});
this.torrentService.rateLimitStatus$.subscribe((status) => { this.torrentService.rateLimitStatus$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((status) => {
this.rateLimitStatus = status; this.rateLimitStatus = status;
}); });
this.torrentService.update$.subscribe((result) => { this.torrentService.update$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => {
this.torrents = result; this.setTorrents(result);
}); });
this.torrentService.getList().subscribe({ this.torrentService
next: (result) => { .getList()
this.torrents = result; .pipe(takeUntilDestroyed(this.destroyRef))
}, .subscribe({
error: (err) => { next: (result) => {
this.error = err.error; this.setTorrents(result);
}, },
}); error: (err) => {
this.error = err.error;
},
});
} }
public sort(property: string): void { public sort(property: string): void {
@ -131,30 +144,33 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
} catch (_) { } catch (_) {
// Ignore storage errors // Ignore storage errors
} }
this.applySorting();
} }
public openTorrent(torrentId: string): void { public openTorrent(torrentId: string): void {
this.router.navigate([`/torrent/${torrentId}`]); this.router.navigate([`/torrent/${torrentId}`]);
} }
public toggleDeleteSelectAll(event: any) { public toggleDeleteSelectAll(event: Event) {
this.selectedTorrents = []; const checked = (event.target as HTMLInputElement).checked;
if (event.target.checked) { this.selectedTorrentIds = checked ? new Set(this.torrents.map((torrent) => torrent.torrentId)) : new Set<string>();
this.torrents.map((torrent) => { this.syncSelectedTorrents();
this.selectedTorrents.push(torrent.torrentId);
});
}
} }
public toggleSelect(torrentId: string) { public toggleSelect(torrentId: string) {
const index = this.selectedTorrents.indexOf(torrentId); if (this.selectedTorrentIds.has(torrentId)) {
this.selectedTorrentIds.delete(torrentId);
if (index > -1) {
this.selectedTorrents.splice(index, 1);
} else { } else {
this.selectedTorrents.push(torrentId); this.selectedTorrentIds.add(torrentId);
} }
this.syncSelectedTorrents();
}
public isSelected(torrentId: string): boolean {
return this.selectedTorrentIds.has(torrentId);
} }
public showDeleteModal(): void { public showDeleteModal(): void {
@ -184,7 +200,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
this.isDeleteModalActive = false; this.isDeleteModalActive = false;
this.deleting = false; this.deleting = false;
this.selectedTorrents = []; this.clearSelectedTorrents();
}, },
error: (err) => { error: (err) => {
this.deleteError = err.error; this.deleteError = err.error;
@ -217,7 +233,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
this.isRetryModalActive = false; this.isRetryModalActive = false;
this.retrying = false; this.retrying = false;
this.selectedTorrents = []; this.clearSelectedTorrents();
}, },
error: (err) => { error: (err) => {
this.retryError = err.error; this.retryError = err.error;
@ -229,7 +245,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
public changeSettingsModal(): void { public changeSettingsModal(): void {
this.changeSettingsError = null; this.changeSettingsError = null;
const selectedTorrents = this.torrents.filter((m) => this.selectedTorrents.indexOf(m.torrentId) > -1); const selectedTorrents = this.getSelectedTorrentModels();
this.updateSettingsDownloadClient = selectedTorrents.every( this.updateSettingsDownloadClient = selectedTorrents.every(
(m, _, arr) => m.downloadClient === arr[0].downloadClient, (m, _, arr) => m.downloadClient === arr[0].downloadClient,
@ -276,7 +292,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
const calls: Observable<void>[] = []; const calls: Observable<void>[] = [];
const selectedTorrents = this.torrents.filter((m) => this.selectedTorrents.indexOf(m.torrentId) > -1); const selectedTorrents = this.getSelectedTorrentModels();
selectedTorrents.forEach((torrent) => { selectedTorrents.forEach((torrent) => {
if (this.updateSettingsDownloadClient != null) { if (this.updateSettingsDownloadClient != null) {
@ -292,7 +308,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
torrent.priority = this.updateSettingsPriority; torrent.priority = this.updateSettingsPriority;
} }
if (this.updateSettingsDownloadRetryAttempts != null) { if (this.updateSettingsDownloadRetryAttempts != null) {
torrent.retryCount = this.updateSettingsDownloadRetryAttempts; torrent.downloadRetryAttempts = this.updateSettingsDownloadRetryAttempts;
} }
if (this.updateSettingsTorrentRetryAttempts != null) { if (this.updateSettingsTorrentRetryAttempts != null) {
torrent.torrentRetryAttempts = this.updateSettingsTorrentRetryAttempts; torrent.torrentRetryAttempts = this.updateSettingsTorrentRetryAttempts;
@ -312,7 +328,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
this.isChangeSettingsModalActive = false; this.isChangeSettingsModalActive = false;
this.changingSettings = false; this.changingSettings = false;
this.selectedTorrents = []; this.clearSelectedTorrents();
}, },
error: (err) => { error: (err) => {
this.changeSettingsError = err.error; this.changeSettingsError = err.error;
@ -333,4 +349,52 @@ export class TorrentTableComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.mobileQuery?.removeEventListener('change', this.mobileQueryListener); this.mobileQuery?.removeEventListener('change', this.mobileQueryListener);
} }
private setTorrents(torrents: Torrent[]): void {
this.torrents = torrents;
this.pruneSelectedTorrents();
this.applySorting();
}
private applySorting(): void {
this.sortedTorrents = sortItems(this.torrents, this.sortProperty, this.sortDirection, (torrent, field) => {
switch (field) {
case 'files.length':
return torrent.filesCount ?? torrent.files?.length ?? 0;
case 'downloads.length':
return torrent.downloadsCount ?? torrent.downloads?.length ?? 0;
case 'status':
return torrent.statusText ?? getTorrentStatus(torrent);
default:
return getSortFieldValue(torrent, field);
}
});
}
private getSelectedTorrentModels(): Torrent[] {
return this.torrents.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId));
}
private pruneSelectedTorrents(): void {
const torrentIds = new Set(this.torrents.map((torrent) => torrent.torrentId));
for (const torrentId of this.selectedTorrentIds) {
if (!torrentIds.has(torrentId)) {
this.selectedTorrentIds.delete(torrentId);
}
}
this.syncSelectedTorrents();
}
private clearSelectedTorrents(): void {
this.selectedTorrentIds.clear();
this.syncSelectedTorrents();
}
private syncSelectedTorrents(): void {
this.selectedTorrents = this.torrents
.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId))
.map((torrent) => torrent.torrentId);
}
} }

View file

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

View file

@ -9,10 +9,11 @@ public class TorrentData(DataContext dataContext) : ITorrentData
public async Task<IList<Torrent>> Get() public async Task<IList<Torrent>> Get()
{ {
var torrents = await dataContext.Torrents var torrents = await dataContext.Torrents
.AsNoTracking() .AsNoTracking()
.AsSplitQuery() .AsSplitQuery()
.Include(m => m.Downloads) .Include(m => m.Downloads)
.ToListAsync(); .OrderBy(m => m.Priority ?? 9999)
.ToListAsync();
return torrents.OrderBy(m => m.Priority ?? 9999) return torrents.OrderBy(m => m.Priority ?? 9999)
.ThenBy(m => m.Added) .ThenBy(m => m.Added)

View file

@ -8,6 +8,10 @@ namespace RdtClient.Data.Models.Data;
public class Torrent public class Torrent
{ {
private String? _rdFiles;
private IList<DebridClientFile> _filesCache = [];
private Boolean _filesCacheInitialized;
[Key] [Key]
public Guid TorrentId { get; set; } public Guid TorrentId { get; set; }
@ -59,26 +63,51 @@ public class Torrent
public DateTimeOffset? RdEnded { get; set; } public DateTimeOffset? RdEnded { get; set; }
public Int64? RdSpeed { get; set; } public Int64? RdSpeed { get; set; }
public Int64? RdSeeders { get; set; } public Int64? RdSeeders { get; set; }
public String? RdFiles { get; set; } public String? RdFiles
{
get => _rdFiles;
set
{
if (_rdFiles == value)
{
return;
}
_rdFiles = value;
_filesCache = [];
_filesCacheInitialized = false;
}
}
[NotMapped] [NotMapped]
public IList<DebridClientFile> Files public IList<DebridClientFile> Files
{ {
get get
{ {
if (_filesCacheInitialized)
{
return _filesCache;
}
_filesCacheInitialized = true;
if (String.IsNullOrWhiteSpace(RdFiles)) if (String.IsNullOrWhiteSpace(RdFiles))
{ {
return []; _filesCache = [];
return _filesCache;
} }
try try
{ {
return JsonSerializer.Deserialize<List<DebridClientFile>>(RdFiles) ?? []; _filesCache = JsonSerializer.Deserialize<List<DebridClientFile>>(RdFiles) ?? [];
} }
catch catch
{ {
return []; _filesCache = [];
} }
return _filesCache;
} }
} }

View file

@ -21,6 +21,7 @@ public class TorrentDto
public DateTimeOffset? FilesSelected { get; set; } public DateTimeOffset? FilesSelected { get; set; }
public DateTimeOffset? Completed { get; set; } public DateTimeOffset? Completed { get; set; }
public DownloadType Type { get; set; } public DownloadType Type { get; set; }
public String? FileOrMagnet { get; set; }
public Boolean IsFile { get; set; } public Boolean IsFile { get; set; }
public Int32? Priority { get; set; } public Int32? Priority { get; set; }
public Int32 RetryCount { get; set; } public Int32 RetryCount { get; set; }
@ -41,6 +42,9 @@ public class TorrentDto
public DateTimeOffset? RdEnded { get; set; } public DateTimeOffset? RdEnded { get; set; }
public Int64? RdSpeed { get; set; } public Int64? RdSpeed { get; set; }
public Int64? RdSeeders { get; set; } public Int64? RdSeeders { get; set; }
public String StatusText { get; set; } = null!;
public Int32 FilesCount { get; set; }
public Int32 DownloadsCount { get; set; }
public IList<DebridClientFile> Files { get; set; } = []; public IList<DebridClientFile> Files { get; set; } = [];
public IList<DownloadDto> Downloads { get; set; } = []; public IList<DownloadDto> Downloads { get; set; } = [];
} }

View file

@ -0,0 +1,230 @@
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
namespace RdtClient.Service.Helpers;
public static class TorrentDtoMapper
{
public static TorrentDto ToListDto(Torrent torrent, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
return ToDto(torrent, getDownloadStats, includeDownloads: false, includeFiles: false, includeFileOrMagnet: false);
}
public static TorrentDto ToUpdateDto(Torrent torrent, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
return ToDto(torrent, getDownloadStats, includeDownloads: true, includeFiles: false, includeFileOrMagnet: false);
}
public static TorrentDto ToDetailDto(Torrent torrent, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
return ToDto(torrent, getDownloadStats, includeDownloads: true, includeFiles: true, includeFileOrMagnet: true);
}
private static TorrentDto ToDto(Torrent torrent,
Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats,
Boolean includeDownloads,
Boolean includeFiles,
Boolean includeFileOrMagnet)
{
var downloads = includeDownloads ? torrent.Downloads.Select(download => ToDto(download, getDownloadStats)).ToList() : [];
return new()
{
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,
FileOrMagnet = includeFileOrMagnet ? torrent.FileOrMagnet : null,
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,
StatusText = GetStatusText(torrent, getDownloadStats),
FilesCount = torrent.Files.Count,
DownloadsCount = torrent.Downloads.Count,
Files = includeFiles ? torrent.Files : [],
Downloads = downloads
};
}
private static DownloadDto ToDto(Download download, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
var (speed, bytesTotal, bytesDone) = getDownloadStats(download.DownloadId);
return new()
{
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 = bytesTotal,
BytesDone = bytesDone,
Speed = speed
};
}
private static String GetStatusText(Torrent torrent, Func<Guid, (Int64 Speed, Int64 BytesTotal, Int64 BytesDone)> getDownloadStats)
{
if (!String.IsNullOrWhiteSpace(torrent.Error))
{
return torrent.Error;
}
if (torrent.Downloads.Count > 0)
{
var allFinished = true;
var downloadingCount = 0;
var downloadedCount = 0;
Int64 downloadingBytesDone = 0;
Int64 downloadingBytesTotal = 0;
Int64 downloadingSpeed = 0;
var unpackingCount = 0;
var unpackedCount = 0;
Int64 unpackingBytesDone = 0;
Int64 unpackingBytesTotal = 0;
var queuedForUnpackingCount = 0;
var queuedForDownloadingCount = 0;
foreach (var download in torrent.Downloads)
{
if (download.Completed == null)
{
allFinished = false;
}
var (speed, bytesTotal, bytesDone) = getDownloadStats(download.DownloadId);
if (download.DownloadFinished != null)
{
downloadedCount += 1;
}
if (download.DownloadStarted != null && download.DownloadFinished == null && bytesDone > 0)
{
downloadingCount += 1;
downloadingBytesDone += bytesDone;
downloadingBytesTotal += bytesTotal;
downloadingSpeed += speed;
}
if (download.UnpackingFinished != null)
{
unpackedCount += 1;
}
if (download.UnpackingStarted != null && download.UnpackingFinished == null && bytesDone > 0)
{
unpackingCount += 1;
unpackingBytesDone += bytesDone;
unpackingBytesTotal += bytesTotal;
}
if (download.UnpackingQueued != null && download.UnpackingStarted == null)
{
queuedForUnpackingCount += 1;
}
if (download.DownloadStarted == null && download.DownloadFinished == null)
{
queuedForDownloadingCount += 1;
}
}
if (allFinished)
{
return "Finished";
}
if (downloadingCount > 0)
{
var progress = downloadingBytesTotal == 0 ? 0 : (Double)downloadingBytesDone / downloadingBytesTotal * 100;
return $"Downloading file {downloadingCount + downloadedCount}/{torrent.Downloads.Count} ({progress:0.00}% - {FileSizeHelper.FormatSize(downloadingSpeed)}/s)";
}
if (unpackingCount > 0)
{
var progress = unpackingBytesTotal == 0 ? 0 : (Double)unpackingBytesDone / unpackingBytesTotal * 100;
return $"Extracting file {unpackingCount + unpackedCount}/{torrent.Downloads.Count} ({progress:0.00}%)";
}
if (queuedForUnpackingCount > 0)
{
return "Queued for unpacking";
}
if (queuedForDownloadingCount > 0)
{
return "Queued for downloading";
}
if (unpackedCount > 0)
{
return "Files unpacked";
}
if (downloadedCount > 0)
{
return "Files downloaded to host";
}
}
if (torrent.Completed != null)
{
return "Finished";
}
return torrent.RdStatus switch
{
TorrentStatus.Queued => "Not Yet Added to Provider",
TorrentStatus.Downloading when torrent.RdSeeders < 1 && torrent.Type != DownloadType.Nzb => "Torrent stalled",
TorrentStatus.Downloading => $"Torrent downloading ({torrent.RdProgress}% - {FileSizeHelper.FormatSize(torrent.RdSpeed)}/s)",
TorrentStatus.Processing => "Torrent processing",
TorrentStatus.WaitingForFileSelection => "Torrent waiting for file selection",
TorrentStatus.Error => $"Torrent error: {torrent.RdStatusRaw}",
TorrentStatus.Finished => "Torrent finished, waiting for download links",
TorrentStatus.Uploading => "Torrent uploading",
_ => "Unknown status"
};
}
}

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using RdtClient.Data.Models.Internal; using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers;
namespace RdtClient.Service.Services; namespace RdtClient.Service.Services;
@ -9,72 +10,7 @@ public class RemoteService(IHubContext<RdtHub> hub, Torrents torrents)
{ {
var allTorrents = await torrents.Get(); var allTorrents = await torrents.Get();
var torrentDtos = allTorrents.Select(torrent => new TorrentDto var torrentDtos = allTorrents.Select(torrent => TorrentDtoMapper.ToUpdateDto(torrent, torrents.GetDownloadStats))
{
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 =>
{
var (speed, bytesTotal, bytesDone) = torrents.GetDownloadStats(download.DownloadId);
return 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 = bytesTotal,
BytesDone = bytesDone,
Speed = speed
};
})
.ToList()
})
.ToList(); .ToList();
await hub.Clients.All.SendCoreAsync("update", await hub.Clients.All.SendCoreAsync("update",

View file

@ -285,13 +285,12 @@ public class TorrentRunner(
} }
var allTorrents = await torrents.Get(); var allTorrents = await torrents.Get();
var downloadsById = allTorrents.SelectMany(m => m.Downloads).ToDictionary(m => m.DownloadId, m => m);
// Check for deleted torrents that are stuck in the ActiveDownloads or ActiveUnpacks // Check for deleted torrents that are stuck in the ActiveDownloads or ActiveUnpacks
foreach (var activeDownload in ActiveDownloadClients) foreach (var activeDownload in ActiveDownloadClients)
{ {
var download = allTorrents.SelectMany(m => m.Downloads).FirstOrDefault(m => m.DownloadId == activeDownload.Key); if (!downloadsById.ContainsKey(activeDownload.Key))
if (download == null)
{ {
await activeDownload.Value.Cancel(); await activeDownload.Value.Cancel();
ActiveDownloadClients.TryRemove(activeDownload.Key, out _); ActiveDownloadClients.TryRemove(activeDownload.Key, out _);
@ -302,9 +301,7 @@ public class TorrentRunner(
foreach (var activeUnpacks in ActiveUnpackClients) foreach (var activeUnpacks in ActiveUnpackClients)
{ {
var download = allTorrents.SelectMany(m => m.Downloads).FirstOrDefault(m => m.DownloadId == activeUnpacks.Key); if (!downloadsById.ContainsKey(activeUnpacks.Key))
if (download == null)
{ {
activeUnpacks.Value.Cancel(); activeUnpacks.Value.Cancel();
ActiveUnpackClients.TryRemove(activeUnpacks.Key, out _); ActiveUnpackClients.TryRemove(activeUnpacks.Key, out _);

View file

@ -1,9 +1,8 @@
using System.Globalization; using System.Globalization;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml; using System.Xml;
using System.Xml.Linq; using System.Xml.Linq;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -36,11 +35,6 @@ public class Torrents(
{ {
private static readonly SemaphoreSlim RealDebridUpdateLock = new(1, 1); private static readonly SemaphoreSlim RealDebridUpdateLock = new(1, 1);
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles
};
private static readonly SemaphoreSlim TorrentResetLock = new(1, 1); private static readonly SemaphoreSlim TorrentResetLock = new(1, 1);
private IDebridClient DebridClient private IDebridClient DebridClient
@ -646,10 +640,12 @@ public class Torrents(
try try
{ {
var rdTorrents = await DebridClient.GetDownloads(); var rdTorrents = await DebridClient.GetDownloads();
var torrentsByRdId = CreateTorrentLookupByRdId(torrents);
var providerTorrentsById = CreateProviderTorrentLookupById(rdTorrents);
foreach (var rdTorrent in rdTorrents) foreach (var rdTorrent in rdTorrents)
{ {
var torrent = torrents.FirstOrDefault(m => m.RdId == rdTorrent.Id); torrentsByRdId.TryGetValue(rdTorrent.Id, out var torrent);
// Auto import torrents only torrents that have their files selected // Auto import torrents only torrents that have their files selected
if (torrent == null && Settings.Get.Provider.AutoImport) if (torrent == null && Settings.Get.Provider.AutoImport)
@ -680,6 +676,8 @@ public class Torrents(
} }
torrent = await torrentData.Add(rdTorrent.Id, rdTorrent.Hash, null, false, DownloadType.Torrent, Settings.Get.DownloadClient.Client, newTorrent); torrent = await torrentData.Add(rdTorrent.Id, rdTorrent.Hash, null, false, DownloadType.Torrent, Settings.Get.DownloadClient.Client, newTorrent);
torrentsByRdId[rdTorrent.Id] = torrent;
torrents.Add(torrent);
await UpdateTorrentClientData(torrent, rdTorrent); await UpdateTorrentClientData(torrent, rdTorrent);
} }
@ -691,7 +689,7 @@ public class Torrents(
foreach (var torrent in torrents) foreach (var torrent in torrents)
{ {
var rdTorrent = rdTorrents.FirstOrDefault(m => m.Id == torrent.RdId); var rdTorrent = torrent.RdId != null && providerTorrentsById.TryGetValue(torrent.RdId, out var providerTorrent) ? providerTorrent : null;
if (rdTorrent == null && Settings.Get.Provider.AutoDelete && torrent.RdStatus != TorrentStatus.Queued) if (rdTorrent == null && Settings.Get.Provider.AutoDelete && torrent.RdStatus != TorrentStatus.Queued)
{ {
@ -1022,11 +1020,11 @@ public class Torrents(
{ {
try try
{ {
var originalTorrent = JsonSerializer.Serialize(torrent, JsonSerializerOptions); var originalTorrent = CaptureRdState(torrent);
await DebridClient.UpdateData(torrent, torrentClientTorrent); await DebridClient.UpdateData(torrent, torrentClientTorrent);
var newTorrent = JsonSerializer.Serialize(torrent, JsonSerializerOptions); var newTorrent = CaptureRdState(torrent);
if (originalTorrent != newTorrent) if (originalTorrent != newTorrent)
{ {
@ -1039,6 +1037,65 @@ public class Torrents(
} }
} }
private static Dictionary<String, Torrent> CreateTorrentLookupByRdId(IEnumerable<Torrent> torrents)
{
var lookup = new Dictionary<String, Torrent>(StringComparer.Ordinal);
foreach (var torrent in torrents)
{
if (!String.IsNullOrWhiteSpace(torrent.RdId))
{
lookup[torrent.RdId] = torrent;
}
}
return lookup;
}
private static Dictionary<String, DebridClientTorrent> CreateProviderTorrentLookupById(IEnumerable<DebridClientTorrent> torrents)
{
var lookup = new Dictionary<String, DebridClientTorrent>(StringComparer.Ordinal);
foreach (var torrent in torrents)
{
if (!String.IsNullOrWhiteSpace(torrent.Id))
{
lookup[torrent.Id] = torrent;
}
}
return lookup;
}
private static TorrentRdState CaptureRdState(Torrent torrent)
{
return new(torrent.RdName,
torrent.RdSize,
torrent.RdHost,
torrent.RdSplit,
torrent.RdProgress,
torrent.RdStatus,
torrent.RdStatusRaw,
torrent.RdAdded,
torrent.RdEnded,
torrent.RdSpeed,
torrent.RdSeeders,
torrent.RdFiles);
}
private readonly record struct TorrentRdState(String? RdName,
Int64? RdSize,
String? RdHost,
Int64? RdSplit,
Int64? RdProgress,
TorrentStatus? RdStatus,
String? RdStatusRaw,
DateTimeOffset? RdAdded,
DateTimeOffset? RdEnded,
Int64? RdSpeed,
Int64? RdSeeders,
String? RdFiles);
private void Log(String message, Download? download, Torrent? torrent) private void Log(String message, Download? download, Torrent? torrent)
{ {
if (download != null) if (download != null)

View file

@ -21,72 +21,7 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
{ {
var results = await torrents.Get(); var results = await torrents.Get();
var torrentDtos = results.Select(torrent => new TorrentDto var torrentDtos = results.Select(torrent => TorrentDtoMapper.ToListDto(torrent, torrents.GetDownloadStats))
{
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 =>
{
var (speed, bytesTotal, bytesDone) = torrents.GetDownloadStats(download.DownloadId);
return 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 = bytesTotal,
BytesDone = bytesDone,
Speed = speed
};
})
.ToList()
})
.ToList(); .ToList();
return Ok(torrentDtos); return Ok(torrentDtos);
@ -108,72 +43,7 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
file.Torrent = null; file.Torrent = null;
} }
var torrentDto = new TorrentDto var torrentDto = TorrentDtoMapper.ToDetailDto(torrent, torrents.GetDownloadStats);
{
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 =>
{
var (speed, bytesTotal, bytesDone) = torrents.GetDownloadStats(download.DownloadId);
return 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 = bytesTotal,
BytesDone = bytesDone,
Speed = speed
};
})
.ToList()
};
return Ok(torrentDto); return Ok(torrentDto);
} }
@ -240,13 +110,7 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
logger.LogDebug($"Add file"); logger.LogDebug($"Add file");
var fileStream = file.OpenReadStream(); var bytes = await ReadFormFileBytes(file);
await using var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
var bytes = memoryStream.ToArray();
await torrents.AddFileToDebridQueue(bytes, formData.Torrent); await torrents.AddFileToDebridQueue(bytes, formData.Torrent);
@ -302,13 +166,7 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
formData.Torrent.RdName = file.FileName; formData.Torrent.RdName = file.FileName;
} }
var fileStream = file.OpenReadStream(); var bytes = await ReadFormFileBytes(file);
await using var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
var bytes = memoryStream.ToArray();
await torrents.AddNzbFileToDebridQueue(bytes, file.FileName, formData.Torrent); await torrents.AddNzbFileToDebridQueue(bytes, file.FileName, formData.Torrent);
@ -350,13 +208,7 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
return BadRequest("Invalid torrent file"); return BadRequest("Invalid torrent file");
} }
var fileStream = file.OpenReadStream(); var bytes = await ReadFormFileBytes(file);
await using var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
var bytes = memoryStream.ToArray();
var torrent = await MonoTorrent.Torrent.LoadAsync(bytes); var torrent = await MonoTorrent.Torrent.LoadAsync(bytes);
@ -461,13 +313,7 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
} }
else if (file != null) else if (file != null)
{ {
var fileStream = file.OpenReadStream(); var bytes = await ReadFormFileBytes(file);
await using var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
var bytes = memoryStream.ToArray();
var torrent = await MonoTorrent.Torrent.LoadAsync(bytes); var torrent = await MonoTorrent.Torrent.LoadAsync(bytes);
@ -482,36 +328,32 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
if (!String.IsNullOrWhiteSpace(request.IncludeRegex)) if (!String.IsNullOrWhiteSpace(request.IncludeRegex))
{ {
foreach (var availableFile in availableFiles) var includeRegex = BuildRegex(request.IncludeRegex, out includeError);
if (includeRegex != null)
{ {
try foreach (var availableFile in availableFiles)
{ {
if (Regex.IsMatch(availableFile.Filename, request.IncludeRegex)) if (includeRegex.IsMatch(availableFile.Filename))
{ {
selectedFiles.Add(availableFile); selectedFiles.Add(availableFile);
} }
} }
catch (Exception ex)
{
includeError = ex.Message;
}
} }
} }
else if (!String.IsNullOrWhiteSpace(request.ExcludeRegex)) else if (!String.IsNullOrWhiteSpace(request.ExcludeRegex))
{ {
foreach (var availableFile in availableFiles) var excludeRegex = BuildRegex(request.ExcludeRegex, out excludeError);
if (excludeRegex != null)
{ {
try foreach (var availableFile in availableFiles)
{ {
if (!Regex.IsMatch(availableFile.Filename, request.ExcludeRegex)) if (!excludeRegex.IsMatch(availableFile.Filename))
{ {
selectedFiles.Add(availableFile); selectedFiles.Add(availableFile);
} }
} }
catch (Exception ex)
{
excludeError = ex.Message;
}
} }
} }
else else
@ -526,6 +368,32 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
selectedFiles selectedFiles
}); });
} }
private static Regex? BuildRegex(String pattern, out String error)
{
try
{
error = "";
return new(pattern, RegexOptions.Compiled);
}
catch (Exception ex)
{
error = ex.Message;
return null;
}
}
private static async Task<Byte[]> ReadFormFileBytes(IFormFile file)
{
await using var fileStream = file.OpenReadStream();
await using var memoryStream = file.Length > 0 && file.Length <= Int32.MaxValue ? new MemoryStream((Int32)file.Length) : new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
return memoryStream.ToArray();
}
} }
public class TorrentControllerUploadFileRequest public class TorrentControllerUploadFileRequest