Merge pull request #836 from Cucumberrbob/chore/angular-v20

update to angular 20 (plus other frontend gardening)
This commit is contained in:
Roger Far 2025-07-29 13:05:59 -06:00 committed by GitHub
commit 2e8a7ce080
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1517 additions and 1044 deletions

View file

@ -1,5 +1,5 @@
# Stage 1 - Build the frontend
FROM node:18-alpine3.18 AS node-build-env
FROM node:22.16.0-alpine3.22 AS node-build-env
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG BUILDPLATFORM

View file

@ -116,5 +116,31 @@
"cli": {
"analytics": false,
"schematicCollections": ["@angular-eslint/schematics"]
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

1842
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,15 +12,15 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.11",
"@angular/animations": "^20.0.2",
"@angular/cdk": "^19.2.16",
"@angular/common": "^19.2.11",
"@angular/compiler": "^19.2.11",
"@angular/core": "^19.2.11",
"@angular/forms": "^19.2.11",
"@angular/platform-browser": "^19.2.11",
"@angular/platform-browser-dynamic": "^19.2.11",
"@angular/router": "^19.2.11",
"@angular/common": "^20.0.2",
"@angular/compiler": "^20.0.2",
"@angular/core": "^20.0.2",
"@angular/forms": "^20.0.2",
"@angular/platform-browser": "^20.0.2",
"@angular/platform-browser-dynamic": "^20.0.2",
"@angular/router": "^20.0.2",
"@fortawesome/fontawesome-free": "^6.7.2",
"@microsoft/signalr": "^8.0.7",
"bulma": "^1.0.4",
@ -37,10 +37,10 @@
"@angular-eslint/eslint-plugin-template": "19.4.0",
"@angular-eslint/schematics": "19.4.0",
"@angular-eslint/template-parser": "19.4.0",
"@angular/build": "^19.2.12",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "^19.2.11",
"@angular/language-service": "^19.2.11",
"@angular/build": "^20.0.1",
"@angular/cli": "^20.0.1",
"@angular/compiler-cli": "^20.0.2",
"@angular/language-service": "^20.0.2",
"@types/file-saver": "^2.0.7",
"@types/file-saver-es": "^2.0.3",
"@types/node": "^22.15.18",

View file

@ -3,21 +3,6 @@
width: 24em;
}
.scroll-container {
width: 100%;
overflow-x: auto;
overflow-y: auto;
max-height: 603px;
}
.is-fullwidth-label {
width: 100%;
}
.is-block {
display: block;
}
.separator {
display: flex;
align-items: center;
@ -42,7 +27,3 @@
.separator:not(:empty)::after {
margin-left: 0.25em;
}
.strike-through {
text-decoration: line-through;
}

View file

@ -4,12 +4,15 @@ import { TorrentService } from 'src/app/torrent.service';
import { Torrent, TorrentFileAvailability } from '../models/torrent.model';
import { SettingsService } from '../settings.service';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
@Component({
selector: 'app-add-new-torrent',
templateUrl: './add-new-torrent.component.html',
styleUrls: ['./add-new-torrent.component.scss'],
standalone: false,
imports: [FormsModule, NgClass],
standalone: true,
})
export class AddNewTorrentComponent implements OnInit {
public fileName: string;
@ -60,26 +63,25 @@ export class AddNewTorrentComponent implements OnInit {
}
});
this.settingsService.get().subscribe((settings) => {
const providerSetting = settings.first((m) => m.key === 'Provider:Provider');
const providerSetting = settings.find((m) => m.key === 'Provider:Provider');
this.provider = providerSetting.enumValues[providerSetting.value as number];
this.downloadClient = settings.first((m) => m.key === 'DownloadClient:Client')?.value as number;
this.downloadClient = settings.find((m) => m.key === 'DownloadClient:Client')?.value as number;
this.category = settings.first((m) => m.key === 'Gui:Default:Category')?.value as string;
this.hostDownloadAction = this.downloadAction = settings.first((m) => m.key === 'Gui:Default:HostDownloadAction')
this.category = settings.find((m) => m.key === 'Gui:Default:Category')?.value as string;
this.hostDownloadAction = this.downloadAction = settings.find((m) => m.key === 'Gui:Default:HostDownloadAction')
?.value as number;
this.downloadAction =
settings.first((m) => m.key === 'Gui:Default:OnlyDownloadAvailableFiles')?.value === true ? 1 : 0;
this.finishedAction = settings.first((m) => m.key === 'Gui:Default:FinishedAction')?.value as number;
this.finishedActionDelay = settings.first((m) => m.key == 'Gui:Default:FinishedActionDelay')?.value as number;
this.downloadMinSize = settings.first((m) => m.key === 'Gui:Default:MinFileSize')?.value as number;
this.includeRegex = settings.first((m) => m.key === 'Gui:Default:IncludeRegex')?.value as string;
this.excludeRegex = settings.first((m) => m.key === 'Gui:Default:ExcludeRegex')?.value as string;
this.torrentRetryAttempts = settings.first((m) => m.key === 'Gui:Default:TorrentRetryAttempts')?.value as number;
this.downloadRetryAttempts = settings.first((m) => m.key === 'Gui:Default:DownloadRetryAttempts')
?.value as number;
this.torrentDeleteOnError = settings.first((m) => m.key === 'Gui:Default:DeleteOnError')?.value as number;
this.torrentLifetime = settings.first((m) => m.key === 'Gui:Default:TorrentLifetime')?.value as number;
this.priority = settings.first((m) => m.key === 'Gui:Default:Priority')?.value as number;
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();
});
@ -111,25 +113,6 @@ export class AddNewTorrentComponent implements OnInit {
this.checkFiles();
}
public downloadFileChecked(file: string): void {
this.downloadFiles[file] = !this.downloadFiles[file];
this.allSelected = true;
this.availableFiles.forEach((file) => {
if (!this.downloadFiles[file.filename]) {
this.allSelected = false;
}
});
}
public downloadFileCheckedAll(): void {
this.allSelected = !this.allSelected;
this.availableFiles.forEach((file) => {
this.downloadFiles[file.filename] = this.allSelected;
});
}
public ok(): void {
this.saving = true;
this.error = null;
@ -138,7 +121,7 @@ export class AddNewTorrentComponent implements OnInit {
if (this.downloadAction === 2) {
const selectedFiles = [];
for (let filePath in this.downloadFiles) {
for (const filePath in this.downloadFiles) {
if (this.downloadFiles[filePath] === true) {
selectedFiles.push(filePath);
}
@ -170,25 +153,21 @@ export class AddNewTorrentComponent implements OnInit {
torrent.downloadClient = this.downloadClient;
if (this.magnetLink) {
this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe(
() => {
this.router.navigate(['/']);
},
(err) => {
this.torrentService.uploadMagnet(this.magnetLink, torrent).subscribe({
next: () => this.router.navigate(['/']),
error: (err) => {
this.error = err.error;
this.saving = false;
},
);
});
} else if (this.selectedFile) {
this.torrentService.uploadFile(this.selectedFile, torrent).subscribe(
() => {
this.router.navigate(['/']);
},
(err) => {
this.torrentService.uploadFile(this.selectedFile, torrent).subscribe({
next: () => this.router.navigate(['/']),
error: (err) => {
this.error = err.error;
this.saving = false;
},
);
});
} else {
this.error = 'No magnet or file uploaded';
this.saving = false;
@ -213,8 +192,8 @@ export class AddNewTorrentComponent implements OnInit {
this.allSelected = true;
if (this.magnetLink) {
this.torrentService.checkFilesMagnet(this.magnetLink).subscribe(
(result) => {
this.torrentService.checkFilesMagnet(this.magnetLink).subscribe({
next: (result) => {
this.saving = false;
this.availableFiles = result;
this.currentTorrentFile = this.magnetLink;
@ -222,42 +201,30 @@ export class AddNewTorrentComponent implements OnInit {
this.downloadFiles[file.filename] = true;
});
},
(err) => {
error: (err) => {
this.error = err.error;
this.saving = false;
},
);
});
} else if (this.selectedFile) {
this.torrentService.checkFiles(this.selectedFile).subscribe(
(result) => {
this.torrentService.checkFiles(this.selectedFile).subscribe({
next: (result) => {
this.saving = false;
this.availableFiles = result;
result.forEach((file) => {
this.downloadFiles[file.filename] = true;
});
},
(err) => {
error: (err) => {
this.error = err.error;
this.saving = false;
},
);
});
} else {
this.saving = false;
}
}
public isRegexExcluded(file: TorrentFileAvailability): boolean {
if (this.regexSelected == null) {
return false;
}
if (this.regexSelected.find((m) => m.filename === file.filename) == null) {
return true;
}
return false;
}
public verifyRegex(): void {
this.includeRegexError = null;
this.excludeRegexError = null;

View file

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

View file

@ -1,9 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
template: '<router-outlet></router-outlet>',
styles: [],
standalone: false,
selector: 'app-root',
template: '<router-outlet></router-outlet>',
styles: [],
imports: [RouterOutlet],
})
export class AppComponent {}

View file

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

View file

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

View file

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

View file

@ -1,9 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'decodeURI',
standalone: false,
})
@Pipe({ name: 'decodeURI', })
export class DecodeURIPipe implements PipeTransform {
transform(input: any) {
return decodeURI(input);

View file

@ -2,10 +2,7 @@ import { Pipe, PipeTransform } from '@angular/core';
import { Download } from './models/download.model';
import { FileSizePipe } from './filesize.pipe';
@Pipe({
name: 'downloadStatus',
standalone: false,
})
@Pipe({ name: 'downloadStatus' })
export class DownloadStatusPipe implements PipeTransform {
constructor(private pipe: FileSizePipe) {}
@ -27,11 +24,7 @@ export class DownloadStatusPipe implements PipeTransform {
}
if (value.unpackingStarted) {
let progress = (value.bytesDone / value.bytesTotal) * 100;
if (isNaN(progress)) {
progress = 0;
}
const progress = (value.bytesDone / value.bytesTotal || 0) * 100;
return `Unpacking ${progress.toFixed(2)}%`;
}
@ -45,11 +38,7 @@ export class DownloadStatusPipe implements PipeTransform {
}
if (value.downloadStarted) {
let progress = (value.bytesDone / value.bytesTotal) * 100;
if (isNaN(progress)) {
progress = 0;
}
const progress = (value.bytesDone / value.bytesTotal || 0) * 100;
const speed = this.pipe.transform(value.speed, 'filesize');

View file

@ -1,10 +1,7 @@
import { Pipe, PipeTransform } from '@angular/core';
import { filesize } from 'filesize';
@Pipe({
name: 'filesize',
standalone: false,
})
@Pipe({ name: 'filesize', })
export class FileSizePipe implements PipeTransform {
private static transformOne(value: number, options?: any): string {
return filesize(value, options).toString();

View file

@ -7,13 +7,14 @@
<div class="box">
<form (ngSubmit)="login()">
<div class="field">
<label for="" class="label">Username</label>
<label for="userName" class="label">Username</label>
<div class="control has-icons-left">
<input
type="text"
placeholder=""
class="input"
name="userName"
id="userName"
(change)="setUserName($event)"
required
/>
@ -23,9 +24,16 @@
</div>
</div>
<div class="field">
<label for="" class="label" name="password">Password</label>
<label for="password" class="label">Password</label>
<div class="control has-icons-left">
<input type="password" class="input" required name="password" (change)="setPassword($event)" />
<input
type="password"
class="input"
required
name="password"
id="password"
(change)="setPassword($event)"
/>
<span class="icon is-small is-left">
<i class="fa fa-lock"></i>
</span>

View file

@ -1,12 +1,15 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth.service';
import { FormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss'],
standalone: false,
imports: [FormsModule, NgClass],
standalone: true,
})
export class LoginComponent {
public userName: string;
@ -30,14 +33,12 @@ export class LoginComponent {
public login(): void {
this.error = null;
this.loggingIn = true;
this.authService.login(this.userName, this.password).subscribe(
() => {
this.router.navigate(['/']);
},
(err) => {
this.authService.login(this.userName, this.password).subscribe({
next: () => this.router.navigate(['/']),
error: (err) => {
this.loggingIn = false;
this.error = err.error;
},
);
});
}
}

View file

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

View file

@ -1,14 +1,16 @@
import { Component, OnInit } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
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';
@Component({
selector: 'app-navbar',
templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss'],
standalone: false,
imports: [RouterLink, NgClass, DatePipe],
standalone: true,
})
export class NavbarComponent implements OnInit {
public showMobileMenu = false;
@ -58,11 +60,6 @@ export class NavbarComponent implements OnInit {
}
public logout(): void {
this.authService.logout().subscribe(
() => {
this.router.navigate(['/login']);
},
(err) => {},
);
this.authService.logout().subscribe({ next: () => this.router.navigate(['/login']), error: console.error });
}
}

View file

@ -1,10 +1,7 @@
import { Pipe, PipeTransform, SecurityContext, VERSION } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({
name: 'nl2br',
standalone: false,
})
@Pipe({ name: 'nl2br', })
export class Nl2BrPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}

View file

@ -1,11 +1,14 @@
import { Component } from '@angular/core';
import { AuthService } from '../auth.service';
import { FormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
styleUrls: ['./profile.component.scss'],
standalone: false,
imports: [FormsModule, NgClass],
standalone: true,
})
export class ProfileComponent {
constructor(private authService: AuthService) {}
@ -22,16 +25,16 @@ export class ProfileComponent {
this.error = null;
this.saving = true;
this.authService.update(this.username, this.password).subscribe(
() => {
this.authService.update(this.username, this.password).subscribe({
next: () => {
this.success = true;
this.saving = false;
},
(err) => {
error: (err) => {
this.error = err.error;
this.success = false;
this.saving = false;
},
);
});
}
}

View file

@ -1,12 +1,17 @@
import { Component, OnInit } from '@angular/core';
import { SettingsService } from 'src/app/settings.service';
import { Setting } from '../models/setting.model';
import { NgClass, KeyValuePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Nl2BrPipe } from '../nl2br.pipe';
import { FileSizePipe } from '../filesize.pipe';
@Component({
selector: 'app-settings',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
standalone: false,
imports: [NgClass, FormsModule, KeyValuePipe, Nl2BrPipe, FileSizePipe],
standalone: true,
})
export class SettingsComponent implements OnInit {
public activeTab = 0;
@ -39,10 +44,10 @@ export class SettingsComponent implements OnInit {
public reset(): void {
this.settingsService.get().subscribe((settings) => {
this.tabs = settings.where((m) => m.key.indexOf(':') === -1);
this.tabs = settings.filter((m) => m.key.indexOf(':') === -1);
for (let tab of this.tabs) {
tab.settings = settings.where((m) => m.key.indexOf(`${tab.key}:`) > -1);
tab.settings = settings.filter((m) => m.key.indexOf(`${tab.key}:`) > -1);
}
});
}
@ -50,40 +55,39 @@ export class SettingsComponent implements OnInit {
public ok(): void {
this.saving = true;
const settingsToSave = this.tabs.selectMany((m) => m.settings).where((m) => m.type !== 'Object');
const settingsToSave = this.tabs.flatMap((m) => m.settings).filter((m) => m.type !== 'Object');
this.settingsService.update(settingsToSave).subscribe(
() => {
this.settingsService.update(settingsToSave).subscribe({
next: () =>
setTimeout(() => {
this.saving = false;
}, 1000);
},
(err) => {
}, 1000),
error: (err) => {
this.saving = false;
this.error = err;
},
);
});
}
public testDownloadPath(): void {
const settingDownloadPath = this.tabs
.first((m) => m.key === 'DownloadClient')
.settings.first((m) => m.key === 'DownloadClient:DownloadPath').value as string;
.find((m) => m.key === 'DownloadClient')
.settings.find((m) => m.key === 'DownloadClient:DownloadPath').value as string;
this.saving = true;
this.testPathError = null;
this.testPathSuccess = false;
this.settingsService.testPath(settingDownloadPath).subscribe(
() => {
this.settingsService.testPath(settingDownloadPath).subscribe({
next: () => {
this.saving = false;
this.testPathSuccess = true;
},
(err) => {
error: (err) => {
this.testPathError = err.error;
this.saving = false;
},
);
});
}
public testDownloadSpeed(): void {
@ -91,62 +95,64 @@ export class SettingsComponent implements OnInit {
this.testDownloadSpeedError = null;
this.testDownloadSpeedSuccess = 0;
this.settingsService.testDownloadSpeed().subscribe(
(result) => {
this.settingsService.testDownloadSpeed().subscribe({
next: (result) => {
this.saving = false;
this.testDownloadSpeedSuccess = result;
},
(err) => {
error: (err) => {
this.testDownloadSpeedError = err.error;
this.saving = false;
},
);
});
}
public testWriteSpeed(): void {
this.saving = true;
this.testWriteSpeedError = null;
this.testWriteSpeedSuccess = 0;
this.settingsService.testWriteSpeed().subscribe(
(result) => {
this.settingsService.testWriteSpeed().subscribe({
next: (result) => {
this.saving = false;
this.testWriteSpeedSuccess = result;
},
(err) => {
error: (err) => {
this.testWriteSpeedError = err.error;
this.saving = false;
},
);
});
}
public testAria2cConnection(): void {
const settingAria2cUrl = this.tabs
.first((m) => m.key === 'DownloadClient')
.settings.first((m) => m.key === 'DownloadClient:Aria2cUrl').value as string;
.find((m) => m.key === 'DownloadClient')
.settings.find((m) => m.key === 'DownloadClient:Aria2cUrl').value as string;
const settingAria2cSecret = this.tabs
.first((m) => m.key === 'DownloadClient')
.settings.first((m) => m.key === 'DownloadClient:Aria2cSecret').value as string;
.find((m) => m.key === 'DownloadClient')
.settings.find((m) => m.key === 'DownloadClient:Aria2cSecret').value as string;
this.saving = true;
this.testAria2cConnectionError = null;
this.testAria2cConnectionSuccess = null;
this.settingsService.testAria2cConnection(settingAria2cUrl, settingAria2cSecret).subscribe(
(result) => {
this.settingsService.testAria2cConnection(settingAria2cUrl, settingAria2cSecret).subscribe({
next: (result) => {
this.saving = false;
this.testAria2cConnectionSuccess = result.version;
},
(err) => {
error: (err) => {
this.testAria2cConnectionError = err.error;
this.saving = false;
},
);
});
}
public registerMagnetHandler(): void {
try {
navigator.registerProtocolHandler('magnet', `${window.location.origin}/add?magnet=%s`);
alert('Success! Your browser will now prompt you to confirm and add the client as the default handler for magnet links.');
alert(
'Success! Your browser will now prompt you to confirm and add the client as the default handler for magnet links.',
);
} catch (error) {
alert('Magnet link registration failed.');
}

View file

@ -11,18 +11,33 @@
</div>
<form (ngSubmit)="setup()">
<div class="field">
<label for="" class="label">Username</label>
<label for="userName" class="label">Username</label>
<div class="control has-icons-left">
<input type="text" placeholder="" class="input" name="userName" [(ngModel)]="userName" required />
<input
type="text"
placeholder=""
class="input"
name="userName"
id="userName"
[(ngModel)]="userName"
required
/>
<span class="icon is-small is-left">
<i class="fa fa-envelope"></i>
</span>
</div>
</div>
<div class="field">
<label for="" class="label" name="password">Password</label>
<label for="password" class="label">Password</label>
<div class="control has-icons-left">
<input type="password" class="input" required name="password" [(ngModel)]="password" />
<input
type="password"
class="input"
required
name="password"
id="password"
[(ngModel)]="password"
/>
<span class="icon is-small is-left">
<i class="fa fa-lock"></i>
</span>
@ -81,9 +96,17 @@
</div>
</div>
<div class="field">
<label for="" class="label">API Private Token</label>
<label for="debrid-api-token" class="label">API Private Token</label>
<div class="control">
<input type="text" placeholder="" class="input" name="token" [(ngModel)]="token" required />
<input
type="text"
placeholder=""
class="input"
name="token"
id="debrid-api-token"
[(ngModel)]="token"
required
/>
</div>
@if (provider === 0) {
<p class="help">

View file

@ -1,12 +1,15 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth.service';
import { NgClass } from '@angular/common';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-setup',
templateUrl: './setup.component.html',
styleUrls: ['./setup.component.scss'],
standalone: false,
imports: [FormsModule, NgClass],
standalone: true,
})
export class SetupComponent {
public userName: string;
@ -28,29 +31,29 @@ export class SetupComponent {
this.error = null;
this.working = true;
this.authService.create(this.userName, this.password).subscribe(
() => {
this.authService.create(this.userName, this.password).subscribe({
next: () => {
this.step = 2;
this.working = false;
},
(err) => {
error: (err) => {
this.working = false;
this.error = err.error;
},
);
});
}
public setToken(): void {
this.authService.setupProvider(this.provider, this.token).subscribe(
() => {
this.authService.setupProvider(this.provider, this.token).subscribe({
next: () => {
this.step = 3;
this.working = false;
},
(err: any) => {
error: (err: any) => {
this.working = false;
this.error = err.error;
},
);
});
}
public close(): void {

View file

@ -1,9 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'sort',
standalone: false,
})
@Pipe({ name: 'sort', })
export class SortPipe implements PipeTransform {
transform(array: any[], field: string, order: 'asc' | 'desc' = 'asc'): any[] {
if (!Array.isArray(array)) {

View file

@ -2,10 +2,7 @@ import { Pipe, PipeTransform } from '@angular/core';
import { RealDebridStatus, Torrent } from './models/torrent.model';
import { FileSizePipe } from './filesize.pipe';
@Pipe({
name: 'status',
standalone: false,
})
@Pipe({ name: 'status' })
export class TorrentStatusPipe implements PipeTransform {
constructor(private pipe: FileSizePipe) {}
@ -15,59 +12,49 @@ export class TorrentStatusPipe implements PipeTransform {
}
if (torrent.downloads.length > 0) {
const allFinished = torrent.downloads.all((m) => m.completed != null);
const allFinished = torrent.downloads.every((m) => m.completed != null);
if (allFinished) {
return 'Finished';
}
const downloading = torrent.downloads.where((m) => m.downloadStarted && !m.downloadFinished && m.bytesDone > 0);
const downloaded = torrent.downloads.where((m) => m.downloadFinished != null);
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.sum((m) => m.bytesDone);
const bytesTotal = downloading.sum((m) => m.bytesTotal);
let progress = (bytesDone / bytesTotal) * 100;
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;
if (isNaN(progress)) {
progress = 0;
}
const allSpeeds = downloading.reduce((sum, m) => sum + m.speed, 0);
let allSpeeds = downloading.sum((m) => m.speed);
let speed: string | string[] = '0';
speed = this.pipe.transform(allSpeeds, 'filesize');
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.where((m) => m.unpackingStarted && !m.unpackingFinished && m.bytesDone > 0);
const unpacked = torrent.downloads.where((m) => m.unpackingFinished != null);
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.sum((m) => m.bytesDone);
const bytesTotal = unpacking.sum((m) => m.bytesTotal);
let progress = (bytesDone / bytesTotal) * 100;
if (isNaN(progress)) {
progress = 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,
)}%)`;
}
const queuedForUnpacking = torrent.downloads.where((m) => m.unpackingQueued && !m.unpackingStarted);
const queuedForUnpacking = torrent.downloads.filter((m) => m.unpackingQueued && !m.unpackingStarted);
if (queuedForUnpacking.length > 0) {
return `Queued for unpacking`;
}
const queuedForDownload = torrent.downloads.where((m) => !m.downloadStarted && !m.downloadFinished);
const queuedForDownload = torrent.downloads.filter((m) => !m.downloadStarted && !m.downloadFinished);
if (queuedForDownload.length > 0) {
return `Queued for downloading`;

View file

@ -33,7 +33,7 @@
<input
type="checkbox"
(click)="toggleSelect(torrent.torrentId)"
[checked]="selectedTorrents.contains(torrent.torrentId)"
[checked]="selectedTorrents.includes(torrent.torrentId)"
/>
</td>
<td (click)="openTorrent(torrent.torrentId)" class="break-all">

View file

@ -3,12 +3,18 @@ import { Router } from '@angular/router';
import { Torrent } from '../models/torrent.model';
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 { FileSizePipe } from '../filesize.pipe';
@Component({
selector: 'app-torrent-table',
templateUrl: './torrent-table.component.html',
styleUrls: ['./torrent-table.component.scss'],
standalone: false,
imports: [FormsModule, NgClass, DecimalPipe, DatePipe, TorrentStatusPipe, SortPipe, FileSizePipe],
standalone: true,
})
export class TorrentTableComponent implements OnInit {
public torrents: Torrent[] = [];
@ -48,18 +54,18 @@ export class TorrentTableComponent implements OnInit {
) {}
ngOnInit(): void {
this.torrentService.getList().subscribe(
(result) => {
this.torrentService.getList().subscribe({
next: (result) => {
this.torrents = result;
this.torrentService.update$.subscribe((result2) => {
this.torrents = result2;
});
},
(err) => {
error: (err) => {
this.error = err.error;
},
);
});
}
public sort(property: string): void {
@ -107,7 +113,7 @@ export class TorrentTableComponent implements OnInit {
public deleteOk(): void {
this.deleting = true;
let calls: Observable<void>[] = [];
const calls: Observable<void>[] = [];
this.selectedTorrents.forEach((torrentId) => {
calls.push(this.torrentService.delete(torrentId, this.deleteData, this.deleteRdTorrent, this.deleteLocalFiles));
@ -140,7 +146,7 @@ export class TorrentTableComponent implements OnInit {
public retryOk(): void {
this.retrying = true;
let calls: Observable<void>[] = [];
const calls: Observable<void>[] = [];
this.selectedTorrents.forEach((torrentId) => {
calls.push(this.torrentService.retry(torrentId));
@ -163,30 +169,40 @@ export class TorrentTableComponent implements OnInit {
public changeSettingsModal(): void {
this.changeSettingsError = null;
const selectedTorrents = this.torrents.where((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
const selectedTorrents = this.torrents.filter((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
this.updateSettingsDownloadClient =
selectedTorrents.distinctBy((m) => m.downloadClient).count() == 1 ? selectedTorrents[0].downloadClient : null;
this.updateSettingsHostDownloadAction =
selectedTorrents.distinctBy((m) => m.hostDownloadAction).count() == 1
? selectedTorrents[0].hostDownloadAction
: null;
this.updateSettingsCategory =
selectedTorrents.distinctBy((m) => m.category).count() == 1 ? selectedTorrents[0].category : null;
this.updateSettingsPriority =
selectedTorrents.distinctBy((m) => m.priority).count() == 1 ? selectedTorrents[0].priority : null;
this.updateSettingsDownloadRetryAttempts =
selectedTorrents.distinctBy((m) => m.downloadRetryAttempts).count() == 1
? selectedTorrents[0].downloadRetryAttempts
: null;
this.updateSettingsTorrentRetryAttempts =
selectedTorrents.distinctBy((m) => m.torrentRetryAttempts).count() == 1
? selectedTorrents[0].torrentRetryAttempts
: null;
this.updateSettingsDeleteOnError =
selectedTorrents.distinctBy((m) => m.deleteOnError).count() == 1 ? selectedTorrents[0].deleteOnError : null;
this.updateSettingsTorrentLifetime =
selectedTorrents.distinctBy((m) => m.lifetime).count() == 1 ? selectedTorrents[0].lifetime : null;
this.updateSettingsDownloadClient = selectedTorrents.every(
(m, _, arr) => m.downloadClient === arr[0].downloadClient,
)
? selectedTorrents[0].downloadClient
: null;
this.updateSettingsHostDownloadAction = selectedTorrents.every(
(m, _, arr) => m.hostDownloadAction === arr[0].hostDownloadAction,
)
? selectedTorrents[0].hostDownloadAction
: null;
this.updateSettingsCategory = selectedTorrents.every((m, _, arr) => m.category === arr[0].category)
? selectedTorrents[0].category
: null;
this.updateSettingsPriority = selectedTorrents.every((m, _, arr) => m.priority === arr[0].priority)
? selectedTorrents[0].priority
: null;
this.updateSettingsDownloadRetryAttempts = selectedTorrents.every(
(m, _, arr) => m.downloadRetryAttempts === arr[0].downloadRetryAttempts,
)
? selectedTorrents[0].downloadRetryAttempts
: null;
this.updateSettingsTorrentRetryAttempts = selectedTorrents.every(
(m, _, arr) => m.torrentRetryAttempts === arr[0].torrentRetryAttempts,
)
? selectedTorrents[0].torrentRetryAttempts
: null;
this.updateSettingsDeleteOnError = selectedTorrents.every((m, _, arr) => m.deleteOnError === arr[0].deleteOnError)
? selectedTorrents[0].deleteOnError
: null;
this.updateSettingsTorrentLifetime = selectedTorrents.every((m, _, arr) => m.lifetime === arr[0].lifetime)
? selectedTorrents[0].lifetime
: null;
this.isChangeSettingsModalActive = true;
}
@ -198,9 +214,9 @@ export class TorrentTableComponent implements OnInit {
public changeSettingsOk(): void {
this.changingSettings = true;
let calls: Observable<void>[] = [];
const calls: Observable<void>[] = [];
const selectedTorrents = this.torrents.where((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
const selectedTorrents = this.torrents.filter((m) => this.selectedTorrents.indexOf(m.torrentId) > -1);
selectedTorrents.forEach((torrent) => {
if (this.updateSettingsDownloadClient != null) {

View file

@ -8,10 +8,6 @@ table {
}
}
.fa-download {
margin-left: 12px;
}
.flex-container {
display: flex;
flex: 1 1 0;

View file

@ -3,12 +3,29 @@ import { ActivatedRoute, Router } from '@angular/router';
import { saveAs } from 'file-saver-es';
import { Torrent } from '../models/torrent.model';
import { TorrentService } from '../torrent.service';
import { NgClass, DatePipe } from '@angular/common';
import { CdkCopyToClipboard } from '@angular/cdk/clipboard';
import { FormsModule } from '@angular/forms';
import { TorrentStatusPipe } from '../torrent-status.pipe';
import { DownloadStatusPipe } from '../download-status.pipe';
import { DecodeURIPipe } from '../decode-uri.pipe';
import { FileSizePipe } from '../filesize.pipe';
@Component({
selector: 'app-torrent',
templateUrl: './torrent.component.html',
styleUrls: ['./torrent.component.scss'],
standalone: false,
imports: [
NgClass,
CdkCopyToClipboard,
FormsModule,
DatePipe,
TorrentStatusPipe,
DownloadStatusPipe,
DecodeURIPipe,
FileSizePipe,
],
standalone: true,
})
export class TorrentComponent implements OnInit {
public torrent: Torrent;
@ -59,23 +76,21 @@ export class TorrentComponent implements OnInit {
this.activatedRoute.params.subscribe((params) => {
const torrentId = params['id'];
this.torrentService.get(torrentId).subscribe(
(torrent) => {
this.torrentService.get(torrentId).subscribe({
next: (torrent) => {
this.torrent = torrent;
this.torrentService.update$.subscribe((result) => {
this.update(result);
});
},
() => {
this.router.navigate(['/']);
},
);
error: () => this.router.navigate(['/']),
});
});
}
public update(torrents: Torrent[]): void {
const updatedTorrent = torrents.firstOrDefault((m) => m.torrentId === this.torrent.torrentId);
const updatedTorrent = torrents.find((m) => m.torrentId === this.torrent.torrentId);
if (updatedTorrent == null) {
return;
@ -94,7 +109,7 @@ export class TorrentComponent implements OnInit {
}),
);
var blob = new Blob([byteArray], { type: 'application/x-bittorrent' });
const blob = new Blob([byteArray], { type: 'application/x-bittorrent' });
saveAs(blob, `${this.torrent.rdName}.torrent`);
}
@ -116,18 +131,18 @@ export class TorrentComponent implements OnInit {
this.torrentService
.delete(this.torrent.torrentId, this.deleteData, this.deleteRdTorrent, this.deleteLocalFiles)
.subscribe(
() => {
.subscribe({
next: () => {
this.isDeleteModalActive = false;
this.deleting = false;
this.router.navigate(['/']);
},
(err) => {
error: (err) => {
this.deleteError = err.error;
this.deleting = false;
},
);
});
}
public showRetryModal(): void {
@ -143,18 +158,18 @@ export class TorrentComponent implements OnInit {
public retryOk(): void {
this.retrying = true;
this.torrentService.retry(this.torrent.torrentId).subscribe(
() => {
this.torrentService.retry(this.torrent.torrentId).subscribe({
next: () => {
this.isRetryModalActive = false;
this.retrying = false;
this.router.navigate(['/']);
},
(err) => {
error: (err) => {
this.retryError = err.error;
this.retrying = false;
},
);
});
}
public showDownloadRetryModal(downloadId: string): void {
@ -171,16 +186,16 @@ export class TorrentComponent implements OnInit {
public downloadRetryOk(): void {
this.downloadRetrying = true;
this.torrentService.retryDownload(this.downloadRetryId).subscribe(
() => {
this.torrentService.retryDownload(this.downloadRetryId).subscribe({
next: () => {
this.isDownloadRetryModalActive = false;
this.downloadRetrying = false;
},
(err) => {
error: (err) => {
this.downloadRetryError = err.error;
this.downloadRetrying = false;
},
);
});
}
public showUpdateSettingsModal(): void {
@ -212,16 +227,16 @@ export class TorrentComponent implements OnInit {
this.torrent.deleteOnError = this.updateSettingsDeleteOnError;
this.torrent.lifetime = this.updateSettingsTorrentLifetime;
this.torrentService.update(this.torrent).subscribe(
() => {
this.torrentService.update(this.torrent).subscribe({
next: () => {
this.isUpdateSettingsModalActive = false;
this.updating = false;
},
() => {
error: () => {
this.isUpdateSettingsModalActive = false;
this.updating = false;
},
);
});
}
toggleDeleteSelectAllOptions() {
this.deleteData = this.deleteSelectAll;

View file

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

View file

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

View file

@ -14,12 +14,15 @@
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"importHelpers": true,
"strictNullChecks": false,
"target": "ES2022",
"module": "es2020",
"lib": ["es2020", "dom"],
"lib": [
"es2020",
"dom"
],
"useDefineForClassFields": false
},
"angularCompilerOptions": {