diff --git a/client/src/app/add-new-torrent/add-new-torrent.component.ts b/client/src/app/add-new-torrent/add-new-torrent.component.ts index c533ab5..873c6af 100644 --- a/client/src/app/add-new-torrent/add-new-torrent.component.ts +++ b/client/src/app/add-new-torrent/add-new-torrent.component.ts @@ -1,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 { TorrentService } from 'src/app/torrent.service'; import { DownloadType, Torrent, TorrentFileAvailability } from '../models/torrent.model'; @@ -15,6 +16,7 @@ import { NgClass } from '@angular/common'; standalone: true, }) export class AddNewTorrentComponent implements OnInit { + private destroyRef = inject(DestroyRef); private router = inject(Router); private torrentService = inject(TorrentService); private settingsService = inject(SettingsService); @@ -29,8 +31,9 @@ export class AddNewTorrentComponent implements OnInit { public provider: string; public downloadClient: number; - public category: string; + private _category = ''; public categories: string[] = []; + public filteredCategories: string[] = []; public categoryDropdownOpen = false; public hostDownloadAction: number = 0; public downloadAction: number = 0; @@ -56,10 +59,19 @@ export class AddNewTorrentComponent implements OnInit { public excludeRegexError: string; 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 { - this.activatedRoute.queryParams.subscribe((params) => { + this.activatedRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { if (params['type'] === 'nzb') { this.type = 'nzb'; } else if (params['type'] === 'torrent') { @@ -75,45 +87,55 @@ export class AddNewTorrentComponent implements OnInit { 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; - 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; - } - 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.settingsService + .get() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((settings) => { + const providerSetting = settings.find((m) => m.key === 'Provider:Provider'); + this.provider = providerSetting.enumValues[providerSetting.value as number]; + this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.value as number; - this.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[] { - if (!this.category) return this.categories; + private updateFilteredCategories(): void { + if (!this.category) { + this.filteredCategories = this.categories; + return; + } + 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 { @@ -144,7 +166,7 @@ export class AddNewTorrentComponent implements OnInit { public pickFile(evt: Event): void { const files = (evt.target as HTMLInputElement).files; - if (files.length === 0) { + if (files == null || files.length === 0) { return; } diff --git a/client/src/app/models/torrent.model.ts b/client/src/app/models/torrent.model.ts index 29cd9c5..081bb96 100644 --- a/client/src/app/models/torrent.model.ts +++ b/client/src/app/models/torrent.model.ts @@ -44,9 +44,12 @@ export class Torrent { public rdSpeed: number; public rdSeeders: number; public rdFiles: string; + public statusText?: string; + public filesCount?: number; + public downloadsCount?: number; - public files: TorrentFile[]; - public downloads: Download[]; + public files?: TorrentFile[]; + public downloads?: Download[]; } export class TorrentFile { diff --git a/client/src/app/navbar/navbar.component.ts b/client/src/app/navbar/navbar.component.ts index a5c159f..80ffe36 100644 --- a/client/src/app/navbar/navbar.component.ts +++ b/client/src/app/navbar/navbar.component.ts @@ -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 { AuthService } from '../auth.service'; import { Profile } from '../models/profile.model'; import { SettingsService } from '../settings.service'; import { NgClass, DatePipe } from '@angular/common'; +import { filter } from 'rxjs'; @Component({ selector: 'app-navbar', @@ -13,6 +15,7 @@ import { NgClass, DatePipe } from '@angular/common'; standalone: true, }) export class NavbarComponent implements OnInit { + private destroyRef = inject(DestroyRef); private settingsService = inject(SettingsService); private authService = inject(AuthService); private router = inject(Router); @@ -24,39 +27,48 @@ export class NavbarComponent implements OnInit { public version: string; constructor() { - this.router.events.subscribe((event) => { - if (event instanceof NavigationEnd) { + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { this.showMobileMenu = false; - } - }); + }); } ngOnInit(): void { - this.settingsService.getProfile().subscribe((result) => { - this.profile = result; + this.settingsService + .getProfile() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + this.profile = result; - switch (result.provider) { - case 'RealDebrid': - this.providerLink = 'https://real-debrid.com/?id=1348683'; - break; - case 'AllDebrid': - this.providerLink = 'https://alldebrid.com/?uid=2v91l&lang=en'; - break; - case 'Premiumize': - this.providerLink = 'https://www.premiumize.me/'; - break; - case 'TorBox': - this.providerLink = 'https://torbox.app/'; - break; - case 'DebridLink': - this.providerLink = 'https://debrid-link.com/'; - break; - } - }); + switch (result.provider) { + case 'RealDebrid': + this.providerLink = 'https://real-debrid.com/?id=1348683'; + break; + case 'AllDebrid': + this.providerLink = 'https://alldebrid.com/?uid=2v91l&lang=en'; + break; + case 'Premiumize': + this.providerLink = 'https://www.premiumize.me/'; + break; + case 'TorBox': + this.providerLink = 'https://torbox.app/'; + break; + case 'DebridLink': + this.providerLink = 'https://debrid-link.com/'; + break; + } + }); - this.settingsService.getVersion().subscribe((result) => { - this.version = result.version; - }); + this.settingsService + .getVersion() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + this.version = result.version; + }); } public logout(): void { diff --git a/client/src/app/sort.pipe.ts b/client/src/app/sort.pipe.ts index e020aa0..48de51b 100644 --- a/client/src/app/sort.pipe.ts +++ b/client/src/app/sort.pipe.ts @@ -1,20 +1,78 @@ 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((value, key) => { + if (value == null || typeof value !== 'object') { + return undefined; + } + + return (value as Record)[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( + array: readonly T[], + field: string, + order: SortDirection = 'asc', + accessor: (item: T, field: string) => unknown = getSortFieldValue, +): T[] { + if (!Array.isArray(array)) { + return []; + } + + const direction = order === 'asc' ? 1 : -1; + + return [...array].sort((left, right) => compareSortValues(accessor(left, field), accessor(right, field)) * direction); +} + @Pipe({ name: 'sort' }) export class SortPipe implements PipeTransform { - transform(array: any[], field: string, order: 'asc' | 'desc' = 'asc'): any[] { - if (!Array.isArray(array)) { - return []; - } - const sortedArray = array.sort((a, b) => { - if (a[field] < b[field]) { - return -1; - } else if (a[field] > b[field]) { - return 1; - } else { - return 0; - } - }); - return order === 'asc' ? sortedArray : sortedArray.reverse(); + transform(array: unknown[], field: string, order: SortDirection = 'asc'): unknown[] { + return sortItems(array, field, order); } } diff --git a/client/src/app/torrent-status.pipe.ts b/client/src/app/torrent-status.pipe.ts index a31a9c3..2f58d9d 100644 --- a/client/src/app/torrent-status.pipe.ts +++ b/client/src/app/torrent-status.pipe.ts @@ -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 { FileSizePipe } from './filesize.pipe'; -@Pipe({ name: 'status' }) -export class TorrentStatusPipe implements PipeTransform { - private pipe = inject(FileSizePipe); +const fileSizePipe = new FileSizePipe(); - transform(torrent: Torrent): string { - if (torrent.error) { - return torrent.error; - } +export function getTorrentStatus(torrent: Torrent): string { + if (torrent.error) { + return torrent.error; + } - if (torrent.downloads.length > 0) { - const allFinished = torrent.downloads.every((m) => m.completed != null); + const downloads = torrent.downloads ?? []; - if (allFinished) { - return 'Finished'; + if (downloads.length > 0) { + 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); - const downloaded = torrent.downloads.filter((m) => m.downloadFinished != null); - - if (downloading.length > 0) { - const bytesDone = downloading.reduce((sum, m) => sum + m.bytesDone, 0); - const bytesTotal = downloading.reduce((sum, m) => sum + m.bytesTotal, 0); - const progress = (bytesDone / bytesTotal || 0) * 100; - - const allSpeeds = downloading.reduce((sum, m) => sum + m.speed, 0); - - const speed: string | string[] = this.pipe.transform(allSpeeds, 'filesize'); - - return `Downloading file ${downloading.length + downloaded.length}/${ - torrent.downloads.length - } (${progress.toFixed(2)}% - ${speed}/s)`; + if (download.downloadFinished != null) { + downloadedCount += 1; } - const unpacking = torrent.downloads.filter((m) => m.unpackingStarted && !m.unpackingFinished && m.bytesDone > 0); - const unpacked = torrent.downloads.filter((m) => m.unpackingFinished != null); - - if (unpacking.length > 0) { - const bytesDone = unpacking.reduce((sum, m) => sum + m.bytesDone, 0); - const bytesTotal = unpacking.reduce((sum, m) => sum + m.bytesTotal, 0); - const progress = (bytesDone / bytesTotal || 0) * 100; - - return `Extracting file ${unpacking.length + unpacked.length}/${torrent.downloads.length} (${progress.toFixed( - 2, - )}%)`; + if (download.downloadStarted && !download.downloadFinished && download.bytesDone > 0) { + downloadingCount += 1; + downloadingBytesDone += download.bytesDone; + downloadingBytesTotal += download.bytesTotal; + downloadingSpeed += download.speed; } - const queuedForUnpacking = torrent.downloads.filter((m) => m.unpackingQueued && !m.unpackingStarted); - - if (queuedForUnpacking.length > 0) { - return `Queued for unpacking`; + if (download.unpackingFinished != null) { + unpackedCount += 1; } - const queuedForDownload = torrent.downloads.filter((m) => !m.downloadStarted && !m.downloadFinished); - - if (queuedForDownload.length > 0) { - return `Queued for downloading`; + if (download.unpackingStarted && !download.unpackingFinished && download.bytesDone > 0) { + unpackingCount += 1; + unpackingBytesDone += download.bytesDone; + unpackingBytesTotal += download.bytesTotal; } - if (unpacked.length > 0) { - return `Files unpacked`; + if (download.unpackingQueued && !download.unpackingStarted) { + queuedForUnpackingCount += 1; } - if (downloaded.length > 0) { - return `Files downloaded to host`; + if (!download.downloadStarted && !download.downloadFinished) { + queuedForDownloadingCount += 1; } } - if (torrent.completed) { + if (allFinished) { 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`; - } - const speed = this.pipe.transform(torrent.rdSpeed, 'filesize'); - return `Torrent downloading (${torrent.rdProgress}% - ${speed}/s)`; - case RealDebridStatus.Processing: - return `Torrent processing`; - case RealDebridStatus.WaitingForFileSelection: - return `Torrent waiting for file selection`; - case RealDebridStatus.Error: - return `Torrent error: ${torrent.rdStatusRaw}`; - case RealDebridStatus.Finished: - return `Torrent finished, waiting for download links`; - case RealDebridStatus.Uploading: - return `Torrent uploading`; - default: - return 'Unknown status'; + if (downloadingCount > 0) { + const progress = ((downloadingBytesDone / downloadingBytesTotal) || 0) * 100; + const speed = fileSizePipe.transform(downloadingSpeed, 'filesize') as string; + + return `Downloading file ${downloadingCount + downloadedCount}/${downloads.length} (${progress.toFixed(2)}% - ${speed}/s)`; + } + + if (unpackingCount > 0) { + const progress = ((unpackingBytesDone / unpackingBytesTotal) || 0) * 100; + + return `Extracting file ${unpackingCount + unpackedCount}/${downloads.length} (${progress.toFixed(2)}%)`; + } + + if (queuedForUnpackingCount > 0) { + return 'Queued for unpacking'; + } + + if (queuedForDownloadingCount > 0) { + return 'Queued for downloading'; + } + + if (unpackedCount > 0) { + return 'Files unpacked'; + } + + if (downloadedCount > 0) { + return 'Files downloaded to host'; } } + + if (torrent.completed) { + return 'Finished'; + } + + 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); + } } diff --git a/client/src/app/torrent-table/torrent-table.component.html b/client/src/app/torrent-table/torrent-table.component.html index dc1054c..161c021 100644 --- a/client/src/app/torrent-table/torrent-table.component.html +++ b/client/src/app/torrent-table/torrent-table.component.html @@ -47,14 +47,10 @@ - @for (torrent of torrents | sort: sortProperty : sortDirection; track torrent.torrentId) { + @for (torrent of sortedTorrents; track torrent.torrentId) { - + {{ torrent.rdName }} @@ -69,10 +65,10 @@ {{ torrent.rdSeeders }} - {{ torrent.files.length | number }} + {{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }} - {{ torrent.downloads.length | number }} + {{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }} {{ torrent.rdSize | filesize }} @@ -81,7 +77,7 @@ {{ torrent.added | date: "medium" }} - {{ torrent | status }} + {{ torrent.statusText ?? (torrent | status) }} } @@ -89,19 +85,19 @@ } @else {
- @for (torrent of torrents | sort: sortProperty : sortDirection; track torrent.torrentId) { -
+ @for (torrent of sortedTorrents; track torrent.torrentId) { +

{{ torrent.rdName }}

- {{ torrent | status }} + {{ torrent.statusText ?? (torrent | status) }} @if (torrent.category) { {{ torrent.category }} } @@ -112,10 +108,10 @@ > Seeders {{ torrent.rdSeeders }} Files {{ torrent.files.length | number }}Files {{ (torrent.filesCount ?? torrent.files?.length ?? 0) | number }} Downloads {{ torrent.downloads.length | number }}Downloads {{ (torrent.downloadsCount ?? torrent.downloads?.length ?? 0) | number }}
diff --git a/client/src/app/torrent-table/torrent-table.component.ts b/client/src/app/torrent-table/torrent-table.component.ts index c049aa3..feed203 100644 --- a/client/src/app/torrent-table/torrent-table.component.ts +++ b/client/src/app/torrent-table/torrent-table.component.ts @@ -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 { Torrent } from '../models/torrent.model'; import { DiskSpaceStatus } from '../models/disk-space-status.model'; @@ -7,26 +8,29 @@ import { TorrentService } from '../torrent.service'; import { forkJoin, Observable } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { NgClass, DecimalPipe, DatePipe } from '@angular/common'; -import { TorrentStatusPipe } from '../torrent-status.pipe'; -import { SortPipe } from '../sort.pipe'; +import { getTorrentStatus, TorrentStatusPipe } from '../torrent-status.pipe'; +import { SortDirection, getSortFieldValue, sortItems } from '../sort.pipe'; import { FileSizePipe } from '../filesize.pipe'; @Component({ selector: 'app-torrent-table', templateUrl: './torrent-table.component.html', styleUrls: ['./torrent-table.component.scss'], - imports: [FormsModule, NgClass, DecimalPipe, DatePipe, TorrentStatusPipe, SortPipe, FileSizePipe], + imports: [FormsModule, NgClass, DecimalPipe, DatePipe, TorrentStatusPipe, FileSizePipe], standalone: true, }) export class TorrentTableComponent implements OnInit, OnDestroy { + private destroyRef = inject(DestroyRef); private router = inject(Router); private torrentService = inject(TorrentService); + private selectedTorrentIds = new Set(); public torrents: Torrent[] = []; + public sortedTorrents: Torrent[] = []; public selectedTorrents: string[] = []; public error: string; public sortProperty = 'added'; - public sortDirection: 'asc' | 'desc' = 'desc'; + public sortDirection: SortDirection = 'desc'; public isDeleteModalActive: boolean; public deleteError: string; @@ -82,38 +86,47 @@ export class TorrentTableComponent implements OnInit, OnDestroy { // Ignore storage errors (e.g., disabled storage) } - this.torrentService.getDiskSpaceStatus().subscribe({ - next: (status) => { - this.diskSpaceStatus = status; - }, - }); + this.torrentService + .getDiskSpaceStatus() + .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.torrentService.getRateLimitStatus().subscribe({ - next: (status) => { - this.rateLimitStatus = status; - }, - }); + this.torrentService + .getRateLimitStatus() + .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.torrentService.update$.subscribe((result) => { - this.torrents = result; + this.torrentService.update$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => { + this.setTorrents(result); }); - this.torrentService.getList().subscribe({ - next: (result) => { - this.torrents = result; - }, - error: (err) => { - this.error = err.error; - }, - }); + this.torrentService + .getList() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + this.setTorrents(result); + }, + error: (err) => { + this.error = err.error; + }, + }); } public sort(property: string): void { @@ -131,30 +144,33 @@ export class TorrentTableComponent implements OnInit, OnDestroy { } catch (_) { // Ignore storage errors } + + this.applySorting(); } public openTorrent(torrentId: string): void { this.router.navigate([`/torrent/${torrentId}`]); } - public toggleDeleteSelectAll(event: any) { - this.selectedTorrents = []; + public toggleDeleteSelectAll(event: Event) { + const checked = (event.target as HTMLInputElement).checked; - if (event.target.checked) { - this.torrents.map((torrent) => { - this.selectedTorrents.push(torrent.torrentId); - }); - } + this.selectedTorrentIds = checked ? new Set(this.torrents.map((torrent) => torrent.torrentId)) : new Set(); + this.syncSelectedTorrents(); } public toggleSelect(torrentId: string) { - const index = this.selectedTorrents.indexOf(torrentId); - - if (index > -1) { - this.selectedTorrents.splice(index, 1); + if (this.selectedTorrentIds.has(torrentId)) { + this.selectedTorrentIds.delete(torrentId); } else { - this.selectedTorrents.push(torrentId); + this.selectedTorrentIds.add(torrentId); } + + this.syncSelectedTorrents(); + } + + public isSelected(torrentId: string): boolean { + return this.selectedTorrentIds.has(torrentId); } public showDeleteModal(): void { @@ -184,7 +200,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy { this.isDeleteModalActive = false; this.deleting = false; - this.selectedTorrents = []; + this.clearSelectedTorrents(); }, error: (err) => { this.deleteError = err.error; @@ -217,7 +233,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy { this.isRetryModalActive = false; this.retrying = false; - this.selectedTorrents = []; + this.clearSelectedTorrents(); }, error: (err) => { this.retryError = err.error; @@ -229,7 +245,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy { public changeSettingsModal(): void { this.changeSettingsError = null; - const selectedTorrents = this.torrents.filter((m) => this.selectedTorrents.indexOf(m.torrentId) > -1); + const selectedTorrents = this.getSelectedTorrentModels(); this.updateSettingsDownloadClient = selectedTorrents.every( (m, _, arr) => m.downloadClient === arr[0].downloadClient, @@ -276,7 +292,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy { const calls: Observable[] = []; - const selectedTorrents = this.torrents.filter((m) => this.selectedTorrents.indexOf(m.torrentId) > -1); + const selectedTorrents = this.getSelectedTorrentModels(); selectedTorrents.forEach((torrent) => { if (this.updateSettingsDownloadClient != null) { @@ -292,7 +308,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy { torrent.priority = this.updateSettingsPriority; } if (this.updateSettingsDownloadRetryAttempts != null) { - torrent.retryCount = this.updateSettingsDownloadRetryAttempts; + torrent.downloadRetryAttempts = this.updateSettingsDownloadRetryAttempts; } if (this.updateSettingsTorrentRetryAttempts != null) { torrent.torrentRetryAttempts = this.updateSettingsTorrentRetryAttempts; @@ -312,7 +328,7 @@ export class TorrentTableComponent implements OnInit, OnDestroy { this.isChangeSettingsModalActive = false; this.changingSettings = false; - this.selectedTorrents = []; + this.clearSelectedTorrents(); }, error: (err) => { this.changeSettingsError = err.error; @@ -333,4 +349,52 @@ export class TorrentTableComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.mobileQuery?.removeEventListener('change', this.mobileQueryListener); } + + private setTorrents(torrents: Torrent[]): void { + this.torrents = torrents; + this.pruneSelectedTorrents(); + this.applySorting(); + } + + private applySorting(): void { + this.sortedTorrents = sortItems(this.torrents, this.sortProperty, this.sortDirection, (torrent, field) => { + switch (field) { + case 'files.length': + return torrent.filesCount ?? torrent.files?.length ?? 0; + case 'downloads.length': + return torrent.downloadsCount ?? torrent.downloads?.length ?? 0; + case 'status': + return torrent.statusText ?? getTorrentStatus(torrent); + default: + return getSortFieldValue(torrent, field); + } + }); + } + + private getSelectedTorrentModels(): Torrent[] { + return this.torrents.filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId)); + } + + private pruneSelectedTorrents(): void { + const torrentIds = new Set(this.torrents.map((torrent) => torrent.torrentId)); + + for (const torrentId of this.selectedTorrentIds) { + if (!torrentIds.has(torrentId)) { + this.selectedTorrentIds.delete(torrentId); + } + } + + this.syncSelectedTorrents(); + } + + private clearSelectedTorrents(): void { + this.selectedTorrentIds.clear(); + this.syncSelectedTorrents(); + } + + private syncSelectedTorrents(): void { + this.selectedTorrents = this.torrents + .filter((torrent) => this.selectedTorrentIds.has(torrent.torrentId)) + .map((torrent) => torrent.torrentId); + } } diff --git a/client/src/app/torrent/torrent.component.ts b/client/src/app/torrent/torrent.component.ts index 34cffb8..561f44b 100644 --- a/client/src/app/torrent/torrent.component.ts +++ b/client/src/app/torrent/torrent.component.ts @@ -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 { saveAs } from 'file-saver-es'; import { Torrent } from '../models/torrent.model'; @@ -10,6 +11,7 @@ import { TorrentStatusPipe } from '../torrent-status.pipe'; import { DownloadStatusPipe } from '../download-status.pipe'; import { DecodeURIPipe } from '../decode-uri.pipe'; import { FileSizePipe } from '../filesize.pipe'; +import { EMPTY, distinctUntilChanged, map, switchMap, tap, catchError } from 'rxjs'; @Component({ selector: 'app-torrent', @@ -28,9 +30,11 @@ import { FileSizePipe } from '../filesize.pipe'; standalone: true, }) export class TorrentComponent implements OnInit { + private destroyRef = inject(DestroyRef); private activatedRoute = inject(ActivatedRoute); private router = inject(Router); private torrentService = inject(TorrentService); + private currentTorrentId: string | null = null; public torrent: Torrent; @@ -71,41 +75,65 @@ export class TorrentComponent implements OnInit { public updating: boolean; ngOnInit(): void { - this.activatedRoute.params.subscribe((params) => { - const torrentId = params['id']; - - this.torrentService.get(torrentId).subscribe({ - next: (torrent) => { - this.torrent = torrent; - - this.torrentService.update$.subscribe((result) => { - this.update(result); - }); - }, - error: () => this.router.navigate(['/']), + this.activatedRoute.params + .pipe( + map((params) => params['id'] as string), + distinctUntilChanged(), + tap((torrentId) => { + this.currentTorrentId = torrentId; + }), + switchMap((torrentId) => + this.torrentService.get(torrentId).pipe( + catchError(() => { + this.router.navigate(['/']); + return EMPTY; + }), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((torrent) => { + this.torrent = torrent; + this.currentTorrentId = torrent.torrentId; }); + + this.torrentService.update$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => { + this.update(result); }); } 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) { 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 { - const byteArray = new Uint8Array( - window - .atob(this.torrent.fileOrMagnet) - .split('') - .map(function (c) { - return c.charCodeAt(0); - }), - ); + const binaryString = window.atob(this.torrent.fileOrMagnet); + const byteArray = new Uint8Array(binaryString.length); + + for (let index = 0; index < binaryString.length; index += 1) { + byteArray[index] = binaryString.charCodeAt(index); + } const blob = new Blob([byteArray], { type: 'application/x-bittorrent' }); saveAs(blob, `${this.torrent.rdName}.torrent`); diff --git a/server/RdtClient.Data/Data/TorrentData.cs b/server/RdtClient.Data/Data/TorrentData.cs index a01f0fe..e88afa7 100644 --- a/server/RdtClient.Data/Data/TorrentData.cs +++ b/server/RdtClient.Data/Data/TorrentData.cs @@ -9,10 +9,11 @@ public class TorrentData(DataContext dataContext) : ITorrentData public async Task> Get() { var torrents = await dataContext.Torrents - .AsNoTracking() - .AsSplitQuery() - .Include(m => m.Downloads) - .ToListAsync(); + .AsNoTracking() + .AsSplitQuery() + .Include(m => m.Downloads) + .OrderBy(m => m.Priority ?? 9999) + .ToListAsync(); return torrents.OrderBy(m => m.Priority ?? 9999) .ThenBy(m => m.Added) diff --git a/server/RdtClient.Data/Models/Data/Torrent.cs b/server/RdtClient.Data/Models/Data/Torrent.cs index 575f80b..d060d14 100644 --- a/server/RdtClient.Data/Models/Data/Torrent.cs +++ b/server/RdtClient.Data/Models/Data/Torrent.cs @@ -8,6 +8,10 @@ namespace RdtClient.Data.Models.Data; public class Torrent { + private String? _rdFiles; + private IList _filesCache = []; + private Boolean _filesCacheInitialized; + [Key] public Guid TorrentId { get; set; } @@ -59,26 +63,51 @@ public class Torrent public DateTimeOffset? RdEnded { get; set; } public Int64? RdSpeed { 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] public IList Files { get { + if (_filesCacheInitialized) + { + return _filesCache; + } + + _filesCacheInitialized = true; + if (String.IsNullOrWhiteSpace(RdFiles)) { - return []; + _filesCache = []; + + return _filesCache; } try { - return JsonSerializer.Deserialize>(RdFiles) ?? []; + _filesCache = JsonSerializer.Deserialize>(RdFiles) ?? []; } catch { - return []; + _filesCache = []; } + + return _filesCache; } } diff --git a/server/RdtClient.Data/Models/Internal/TorrentDto.cs b/server/RdtClient.Data/Models/Internal/TorrentDto.cs index 3694199..b6aefc6 100644 --- a/server/RdtClient.Data/Models/Internal/TorrentDto.cs +++ b/server/RdtClient.Data/Models/Internal/TorrentDto.cs @@ -21,6 +21,7 @@ public class TorrentDto public DateTimeOffset? FilesSelected { get; set; } public DateTimeOffset? Completed { get; set; } public DownloadType Type { get; set; } + public String? FileOrMagnet { get; set; } public Boolean IsFile { get; set; } public Int32? Priority { get; set; } public Int32 RetryCount { get; set; } @@ -41,6 +42,9 @@ public class TorrentDto public DateTimeOffset? RdEnded { get; set; } public Int64? RdSpeed { get; set; } public Int64? RdSeeders { get; set; } + public String StatusText { get; set; } = null!; + public Int32 FilesCount { get; set; } + public Int32 DownloadsCount { get; set; } public IList Files { get; set; } = []; public IList Downloads { get; set; } = []; } diff --git a/server/RdtClient.Service/Helpers/TorrentDtoMapper.cs b/server/RdtClient.Service/Helpers/TorrentDtoMapper.cs new file mode 100644 index 0000000..2df76b1 --- /dev/null +++ b/server/RdtClient.Service/Helpers/TorrentDtoMapper.cs @@ -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 getDownloadStats) + { + return ToDto(torrent, getDownloadStats, includeDownloads: false, includeFiles: false, includeFileOrMagnet: false); + } + + public static TorrentDto ToUpdateDto(Torrent torrent, Func getDownloadStats) + { + return ToDto(torrent, getDownloadStats, includeDownloads: true, includeFiles: false, includeFileOrMagnet: false); + } + + public static TorrentDto ToDetailDto(Torrent torrent, Func getDownloadStats) + { + return ToDto(torrent, getDownloadStats, includeDownloads: true, includeFiles: true, includeFileOrMagnet: true); + } + + private static TorrentDto ToDto(Torrent torrent, + Func 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 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 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" + }; + } +} diff --git a/server/RdtClient.Service/Services/RemoteService.cs b/server/RdtClient.Service/Services/RemoteService.cs index 96317ac..2fc7deb 100644 --- a/server/RdtClient.Service/Services/RemoteService.cs +++ b/server/RdtClient.Service/Services/RemoteService.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.SignalR; using RdtClient.Data.Models.Internal; +using RdtClient.Service.Helpers; namespace RdtClient.Service.Services; @@ -9,72 +10,7 @@ public class RemoteService(IHubContext hub, Torrents torrents) { var allTorrents = await torrents.Get(); - var torrentDtos = allTorrents.Select(torrent => new TorrentDto - { - TorrentId = torrent.TorrentId, - Hash = torrent.Hash, - Category = torrent.Category, - DownloadAction = torrent.DownloadAction, - FinishedAction = torrent.FinishedAction, - FinishedActionDelay = torrent.FinishedActionDelay, - HostDownloadAction = torrent.HostDownloadAction, - DownloadMinSize = torrent.DownloadMinSize, - IncludeRegex = torrent.IncludeRegex, - ExcludeRegex = torrent.ExcludeRegex, - DownloadManualFiles = torrent.DownloadManualFiles, - DownloadClient = torrent.DownloadClient, - Added = torrent.Added, - FilesSelected = torrent.FilesSelected, - Completed = torrent.Completed, - 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() - }) + var torrentDtos = allTorrents.Select(torrent => TorrentDtoMapper.ToUpdateDto(torrent, torrents.GetDownloadStats)) .ToList(); await hub.Clients.All.SendCoreAsync("update", diff --git a/server/RdtClient.Service/Services/TorrentRunner.cs b/server/RdtClient.Service/Services/TorrentRunner.cs index e3ede44..bb9da16 100644 --- a/server/RdtClient.Service/Services/TorrentRunner.cs +++ b/server/RdtClient.Service/Services/TorrentRunner.cs @@ -285,13 +285,12 @@ public class TorrentRunner( } 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 foreach (var activeDownload in ActiveDownloadClients) { - var download = allTorrents.SelectMany(m => m.Downloads).FirstOrDefault(m => m.DownloadId == activeDownload.Key); - - if (download == null) + if (!downloadsById.ContainsKey(activeDownload.Key)) { await activeDownload.Value.Cancel(); ActiveDownloadClients.TryRemove(activeDownload.Key, out _); @@ -302,9 +301,7 @@ public class TorrentRunner( foreach (var activeUnpacks in ActiveUnpackClients) { - var download = allTorrents.SelectMany(m => m.Downloads).FirstOrDefault(m => m.DownloadId == activeUnpacks.Key); - - if (download == null) + if (!downloadsById.ContainsKey(activeUnpacks.Key)) { activeUnpacks.Value.Cancel(); ActiveUnpackClients.TryRemove(activeUnpacks.Key, out _); diff --git a/server/RdtClient.Service/Services/Torrents.cs b/server/RdtClient.Service/Services/Torrents.cs index e997f1b..65a08f5 100644 --- a/server/RdtClient.Service/Services/Torrents.cs +++ b/server/RdtClient.Service/Services/Torrents.cs @@ -1,9 +1,8 @@ -using System.Globalization; +using System.Globalization; using System.IO.Abstractions; using System.Security.Cryptography; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using System.Xml; using System.Xml.Linq; using Microsoft.Extensions.Logging; @@ -36,11 +35,6 @@ public class Torrents( { 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 IDebridClient DebridClient @@ -646,10 +640,12 @@ public class Torrents( try { var rdTorrents = await DebridClient.GetDownloads(); + var torrentsByRdId = CreateTorrentLookupByRdId(torrents); + var providerTorrentsById = CreateProviderTorrentLookupById(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 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); + torrentsByRdId[rdTorrent.Id] = torrent; + torrents.Add(torrent); await UpdateTorrentClientData(torrent, rdTorrent); } @@ -691,7 +689,7 @@ public class 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) { @@ -1022,11 +1020,11 @@ public class Torrents( { try { - var originalTorrent = JsonSerializer.Serialize(torrent, JsonSerializerOptions); + var originalTorrent = CaptureRdState(torrent); await DebridClient.UpdateData(torrent, torrentClientTorrent); - var newTorrent = JsonSerializer.Serialize(torrent, JsonSerializerOptions); + var newTorrent = CaptureRdState(torrent); if (originalTorrent != newTorrent) { @@ -1039,6 +1037,65 @@ public class Torrents( } } + private static Dictionary CreateTorrentLookupByRdId(IEnumerable torrents) + { + var lookup = new Dictionary(StringComparer.Ordinal); + + foreach (var torrent in torrents) + { + if (!String.IsNullOrWhiteSpace(torrent.RdId)) + { + lookup[torrent.RdId] = torrent; + } + } + + return lookup; + } + + private static Dictionary CreateProviderTorrentLookupById(IEnumerable torrents) + { + var lookup = new Dictionary(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) { if (download != null) diff --git a/server/RdtClient.Web/Controllers/TorrentsController.cs b/server/RdtClient.Web/Controllers/TorrentsController.cs index 4248b13..9cc1fa1 100644 --- a/server/RdtClient.Web/Controllers/TorrentsController.cs +++ b/server/RdtClient.Web/Controllers/TorrentsController.cs @@ -21,72 +21,7 @@ public class TorrentsController(ILogger logger, Torrents tor { var results = await torrents.Get(); - var torrentDtos = results.Select(torrent => new TorrentDto - { - TorrentId = torrent.TorrentId, - Hash = torrent.Hash, - Category = torrent.Category, - DownloadAction = torrent.DownloadAction, - FinishedAction = torrent.FinishedAction, - FinishedActionDelay = torrent.FinishedActionDelay, - HostDownloadAction = torrent.HostDownloadAction, - DownloadMinSize = torrent.DownloadMinSize, - IncludeRegex = torrent.IncludeRegex, - ExcludeRegex = torrent.ExcludeRegex, - DownloadManualFiles = torrent.DownloadManualFiles, - DownloadClient = torrent.DownloadClient, - Added = torrent.Added, - FilesSelected = torrent.FilesSelected, - Completed = torrent.Completed, - 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() - }) + var torrentDtos = results.Select(torrent => TorrentDtoMapper.ToListDto(torrent, torrents.GetDownloadStats)) .ToList(); return Ok(torrentDtos); @@ -108,72 +43,7 @@ public class TorrentsController(ILogger logger, Torrents tor file.Torrent = null; } - var torrentDto = 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, - 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() - }; + var torrentDto = TorrentDtoMapper.ToDetailDto(torrent, torrents.GetDownloadStats); return Ok(torrentDto); } @@ -240,13 +110,7 @@ public class TorrentsController(ILogger logger, Torrents tor logger.LogDebug($"Add file"); - var fileStream = file.OpenReadStream(); - - await using var memoryStream = new MemoryStream(); - - await fileStream.CopyToAsync(memoryStream); - - var bytes = memoryStream.ToArray(); + var bytes = await ReadFormFileBytes(file); await torrents.AddFileToDebridQueue(bytes, formData.Torrent); @@ -302,13 +166,7 @@ public class TorrentsController(ILogger logger, Torrents tor formData.Torrent.RdName = file.FileName; } - var fileStream = file.OpenReadStream(); - - await using var memoryStream = new MemoryStream(); - - await fileStream.CopyToAsync(memoryStream); - - var bytes = memoryStream.ToArray(); + var bytes = await ReadFormFileBytes(file); await torrents.AddNzbFileToDebridQueue(bytes, file.FileName, formData.Torrent); @@ -350,13 +208,7 @@ public class TorrentsController(ILogger logger, Torrents tor return BadRequest("Invalid torrent file"); } - var fileStream = file.OpenReadStream(); - - await using var memoryStream = new MemoryStream(); - - await fileStream.CopyToAsync(memoryStream); - - var bytes = memoryStream.ToArray(); + var bytes = await ReadFormFileBytes(file); var torrent = await MonoTorrent.Torrent.LoadAsync(bytes); @@ -461,13 +313,7 @@ public class TorrentsController(ILogger logger, Torrents tor } else if (file != null) { - var fileStream = file.OpenReadStream(); - - await using var memoryStream = new MemoryStream(); - - await fileStream.CopyToAsync(memoryStream); - - var bytes = memoryStream.ToArray(); + var bytes = await ReadFormFileBytes(file); var torrent = await MonoTorrent.Torrent.LoadAsync(bytes); @@ -482,36 +328,32 @@ public class TorrentsController(ILogger logger, Torrents tor 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); } } - catch (Exception ex) - { - includeError = ex.Message; - } } } 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); } } - catch (Exception ex) - { - excludeError = ex.Message; - } } } else @@ -526,6 +368,32 @@ public class TorrentsController(ILogger logger, Torrents tor 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 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