mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
Collapse media managers into a single UI page (#843)
This commit is contained in:
1 parent
f1b4bb674c
commit
9f44e0e33f
26 files changed
+1871
-1259
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
tablerAdjustmentsHorizontal,
|
||||
tablerLayoutDashboard,
|
||||
tablerFileText,
|
||||
tablerBell,
|
||||
@@ -50,9 +51,11 @@ import {
|
||||
tablerGripVertical,
|
||||
tablerFilter,
|
||||
tablerPalette,
|
||||
tablerApps,
|
||||
} from '@ng-icons/tabler-icons';
|
||||
|
||||
export const APP_ICONS = {
|
||||
tablerAdjustmentsHorizontal,
|
||||
tablerLayoutDashboard,
|
||||
tablerFileText,
|
||||
tablerBell,
|
||||
@@ -104,4 +107,5 @@ export const APP_ICONS = {
|
||||
tablerGripVertical,
|
||||
tablerFilter,
|
||||
tablerPalette,
|
||||
tablerApps,
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { ShellComponent } from '@layout/shell/shell.component';
|
||||
import { AuthLayoutComponent } from '@layout/auth-layout/auth-layout.component';
|
||||
import { authGuard, setupIncompleteGuard, loginGuard } from '@core/auth/auth.guard';
|
||||
import { pendingChangesGuard } from '@core/guards/pending-changes.guard';
|
||||
import { legacyArrRedirect } from '@features/settings/arr/legacy-arr-redirect';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -98,13 +99,17 @@ export const routes: Routes = [
|
||||
canDeactivate: [pendingChangesGuard],
|
||||
},
|
||||
{
|
||||
path: 'arr/:type',
|
||||
path: 'arr',
|
||||
loadComponent: () =>
|
||||
import(
|
||||
'@features/settings/arr/arr-settings.component'
|
||||
).then((m) => m.ArrSettingsComponent),
|
||||
'@features/settings/arr/arr-apps.component'
|
||||
).then((m) => m.ArrAppsComponent),
|
||||
canDeactivate: [pendingChangesGuard],
|
||||
},
|
||||
{
|
||||
path: 'arr/:type',
|
||||
redirectTo: legacyArrRedirect,
|
||||
},
|
||||
{
|
||||
path: 'download-clients',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SelectOption } from '@ui';
|
||||
|
||||
export interface ArrAppMeta {
|
||||
displayName: string;
|
||||
versionOptions: SelectOption[];
|
||||
defaultPort: number;
|
||||
experimental: boolean;
|
||||
iconSrc: string;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ArrType } from '@shared/models/enums';
|
||||
import type { ArrAppMeta } from './arr-app-meta.model';
|
||||
|
||||
export const ARR_TYPES: ArrType[] = [
|
||||
'sonarr',
|
||||
'radarr',
|
||||
'lidarr',
|
||||
'readarr',
|
||||
'whisparr',
|
||||
'sportarr',
|
||||
'lazylibrarian',
|
||||
];
|
||||
|
||||
export const ARR_APP_META: Record<ArrType, ArrAppMeta> = {
|
||||
sonarr: {
|
||||
displayName: 'Sonarr',
|
||||
versionOptions: [{ label: 'v4', value: 4 }],
|
||||
defaultPort: 8989,
|
||||
experimental: false,
|
||||
iconSrc: 'icons/ext/sonarr-light.svg',
|
||||
},
|
||||
radarr: {
|
||||
displayName: 'Radarr',
|
||||
versionOptions: [{ label: 'v6', value: 6 }],
|
||||
defaultPort: 7878,
|
||||
experimental: false,
|
||||
iconSrc: 'icons/ext/radarr-light.svg',
|
||||
},
|
||||
lidarr: {
|
||||
displayName: 'Lidarr',
|
||||
versionOptions: [{ label: 'v3', value: 3 }],
|
||||
defaultPort: 8686,
|
||||
experimental: false,
|
||||
iconSrc: 'icons/ext/lidarr-light.svg',
|
||||
},
|
||||
readarr: {
|
||||
displayName: 'Readarr',
|
||||
versionOptions: [{ label: 'v0.4', value: 0.4 }],
|
||||
defaultPort: 8787,
|
||||
experimental: false,
|
||||
iconSrc: 'icons/ext/readarr-light.svg',
|
||||
},
|
||||
whisparr: {
|
||||
displayName: 'Whisparr',
|
||||
versionOptions: [{ label: 'v2', value: 2 }, { label: 'v3', value: 3 }],
|
||||
defaultPort: 6969,
|
||||
experimental: false,
|
||||
iconSrc: 'icons/ext/whisparr-light.svg',
|
||||
},
|
||||
sportarr: {
|
||||
displayName: 'Sportarr',
|
||||
versionOptions: [{ label: 'v3', value: 3 }],
|
||||
defaultPort: 1867,
|
||||
experimental: false,
|
||||
iconSrc: 'icons/ext/sportarr-light.svg',
|
||||
},
|
||||
lazylibrarian: {
|
||||
displayName: 'LazyLibrarian',
|
||||
versionOptions: [{ label: 'latest', value: 1 }],
|
||||
defaultPort: 5299,
|
||||
experimental: true,
|
||||
iconSrc: 'icons/ext/lazylibrarian-light.svg',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
<app-page-header
|
||||
title="Media Managers"
|
||||
subtitle="Manage media manager connections"
|
||||
/>
|
||||
|
||||
@if (loadError()) {
|
||||
<app-empty-state
|
||||
icon="tablerPlugConnectedX"
|
||||
heading="Could not connect to server"
|
||||
description="Failed to load settings. Please check that the server is running and try again."
|
||||
>
|
||||
<app-button variant="primary" size="sm" (clicked)="retry()">
|
||||
Retry
|
||||
</app-button>
|
||||
</app-empty-state>
|
||||
} @else if (loader.showSpinner() && !configResource.hasValue()) {
|
||||
<app-loading-state message="Loading settings..." />
|
||||
} @else if (configResource.hasValue()) {
|
||||
<div class="settings-form">
|
||||
@for (entry of configured(); track entry.type) {
|
||||
<app-arr-section
|
||||
[type]="entry.type"
|
||||
[config]="entry.config"
|
||||
[reloading]="configResource.isLoading()"
|
||||
(add)="openAddModal(entry.type)"
|
||||
(edit)="openEditModal(entry.type, $event)"
|
||||
(delete)="deleteInstance(entry.type, $event)"
|
||||
/>
|
||||
} @empty {
|
||||
<app-empty-state
|
||||
heading="No media managers yet"
|
||||
description="Pick an app below to connect it."
|
||||
/>
|
||||
}
|
||||
|
||||
@if (unconfigured().length > 0) {
|
||||
<app-card>
|
||||
<div class="list-header">
|
||||
<h3 class="list-header__title">Add another app</h3>
|
||||
</div>
|
||||
|
||||
<div class="selection-grid">
|
||||
@for (type of unconfigured(); track type) {
|
||||
<button class="selection-card" (click)="openAddModal(type)">
|
||||
<span class="selection-card__icon-wrapper">
|
||||
<img
|
||||
[src]="themeService.themedIconSrc(ARR_APP_META[type].iconSrc)"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="selection-card__icon"
|
||||
/>
|
||||
</span>
|
||||
<span class="selection-card__name">{{ ARR_APP_META[type].displayName }}</span>
|
||||
@if (ARR_APP_META[type].experimental) {
|
||||
<app-badge severity="warning" size="sm">Experimental</app-badge>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<app-modal
|
||||
[title]="(modalIsEdit() ? 'Edit' : 'Add') + ' ' + modalDisplayName() + ' Instance'"
|
||||
[(visible)]="modalVisible"
|
||||
>
|
||||
<div class="modal-form">
|
||||
<app-toggle label="Enabled" [formField]="instanceForm.enabled"
|
||||
hint="Enable or disable this instance"
|
||||
helpKey="arr:enabled" />
|
||||
<app-input label="Name" placeholder="My Instance" [formField]="instanceForm.name"
|
||||
hint="A unique name to identify this instance"
|
||||
[error]="instanceForm.name().errors()[0]?.message"
|
||||
helpKey="arr:name" />
|
||||
<app-input label="URL" [placeholder]="'http://localhost:' + modalMeta().defaultPort" type="url" [formField]="instanceForm.url"
|
||||
hint="Full URL including protocol and port"
|
||||
[error]="instanceForm.url().errors()[0]?.message"
|
||||
helpKey="arr:url" />
|
||||
<app-input label="External URL" [placeholder]="'https://' + modalType() + '.example.com'" type="url" [formField]="instanceForm.externalUrl"
|
||||
hint="Optional URL used in notifications for clickable links (e.g., when internal Docker URLs are not reachable externally)"
|
||||
helpKey="arr:externalUrl" />
|
||||
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [formField]="instanceForm.apiKey"
|
||||
hint="API key from your arr application's Settings > General"
|
||||
[error]="instanceForm.apiKey().errors()[0]?.message"
|
||||
helpKey="arr:apiKey" />
|
||||
<app-select label="API Version" [options]="modalMeta().versionOptions" [formField]="instanceForm.version"
|
||||
hint="API version for this application"
|
||||
helpKey="arr:version" />
|
||||
</div>
|
||||
<div modal-footer>
|
||||
<app-button variant="secondary" size="sm" [loading]="testing()" (clicked)="testConnection()">
|
||||
Test
|
||||
</app-button>
|
||||
<app-button variant="primary" size="sm" [loading]="saving()" [disabled]="hasModalErrors()" (clicked)="saveInstance()">
|
||||
Save
|
||||
</app-button>
|
||||
</div>
|
||||
</app-modal>
|
||||
@@ -0,0 +1,12 @@
|
||||
@use 'settings-layout' as *;
|
||||
@use 'list-layout' as *;
|
||||
|
||||
:host { @include settings-page; }
|
||||
|
||||
.settings-form { @include settings-form; }
|
||||
.modal-form { @include modal-form; }
|
||||
|
||||
.list-header { @include list-header; }
|
||||
|
||||
.selection-grid { @include selection-grid; }
|
||||
.selection-card { @include selection-card; }
|
||||
@@ -0,0 +1,605 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Subject, of, throwError } from 'rxjs';
|
||||
import { ArrApi } from '@core/api/arr.api';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { ThemeService } from '@core/services/theme.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ArrConfig, ArrInstance } from '@shared/models/arr-config.model';
|
||||
import { ArrAppsComponent } from './arr-apps.component';
|
||||
|
||||
const SONARR_INSTANCE: ArrInstance = {
|
||||
id: 'sonarr-1',
|
||||
enabled: true,
|
||||
name: 'Main Sonarr',
|
||||
url: 'http://localhost:8989',
|
||||
externalUrl: 'https://sonarr.example.com',
|
||||
apiKey: 'sonarr-key',
|
||||
version: 4,
|
||||
};
|
||||
|
||||
const RADARR_INSTANCE: ArrInstance = {
|
||||
id: 'radarr-1',
|
||||
enabled: true,
|
||||
name: 'Main Radarr',
|
||||
url: 'http://localhost:7878',
|
||||
apiKey: 'radarr-key',
|
||||
version: 6,
|
||||
};
|
||||
|
||||
const SONARR_CONFIG: ArrConfig = {
|
||||
failedImportMaxStrikes: 3,
|
||||
instances: [SONARR_INSTANCE],
|
||||
};
|
||||
|
||||
const RADARR_CONFIG: ArrConfig = {
|
||||
failedImportMaxStrikes: -1,
|
||||
instances: [RADARR_INSTANCE],
|
||||
};
|
||||
|
||||
const EMPTY_CONFIG: ArrConfig = {
|
||||
failedImportMaxStrikes: -1,
|
||||
instances: [],
|
||||
};
|
||||
|
||||
function createApi(configs: ArrConfig[] = [
|
||||
SONARR_CONFIG, RADARR_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG,
|
||||
]) {
|
||||
return {
|
||||
getConfig: vi.fn((type: string) => {
|
||||
const index = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'sportarr', 'lazylibrarian'].indexOf(type);
|
||||
return of(configs[index] ?? EMPTY_CONFIG);
|
||||
}),
|
||||
updateConfig: vi.fn(() => of(undefined)),
|
||||
createInstance: vi.fn(() => of(SONARR_INSTANCE)),
|
||||
updateInstance: vi.fn(() => of(SONARR_INSTANCE)),
|
||||
deleteInstance: vi.fn(() => of(undefined)),
|
||||
testInstance: vi.fn(() => of({ message: 'Connected' })),
|
||||
};
|
||||
}
|
||||
|
||||
function createToast() {
|
||||
return { success: vi.fn(), error: vi.fn() };
|
||||
}
|
||||
|
||||
interface Setup {
|
||||
fixture: ComponentFixture<ArrAppsComponent>;
|
||||
component: ArrAppsComponent;
|
||||
api: ReturnType<typeof createApi>;
|
||||
toast: ReturnType<typeof createToast>;
|
||||
confirm: ConfirmService;
|
||||
}
|
||||
|
||||
describe('ArrAppsComponent', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setup(api = createApi(), toast = createToast()): Setup {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ArrApi, useValue: api },
|
||||
{ provide: ToastService, useValue: toast },
|
||||
{ provide: ThemeService, useValue: { themedIconSrc: (src: string) => src } },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { fragment: null } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(ArrAppsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
return {
|
||||
fixture,
|
||||
component: fixture.componentInstance,
|
||||
api,
|
||||
toast,
|
||||
confirm: TestBed.inject(ConfirmService),
|
||||
};
|
||||
}
|
||||
|
||||
it('loads all arr types and separates configured from unconfigured', () => {
|
||||
const { component, api } = setup();
|
||||
|
||||
expect(api.getConfig).toHaveBeenCalledTimes(7);
|
||||
expect(component.configured()).toHaveLength(2);
|
||||
expect(component.unconfigured()).toHaveLength(5);
|
||||
expect(component.configured().map(e => e.type)).toEqual(['sonarr', 'radarr']);
|
||||
expect(component.unconfigured()).toEqual(['lidarr', 'readarr', 'whisparr', 'sportarr', 'lazylibrarian']);
|
||||
});
|
||||
|
||||
it('renders configured apps as arr-section components', () => {
|
||||
const { fixture } = setup();
|
||||
|
||||
const sections = fixture.nativeElement.querySelectorAll('app-arr-section');
|
||||
expect(sections.length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders unconfigured apps as selection cards in add strip', () => {
|
||||
const { fixture } = setup();
|
||||
|
||||
const addCard = fixture.nativeElement.querySelector('.list-header__title');
|
||||
expect(addCard.textContent).toContain('Add another app');
|
||||
|
||||
const cards = fixture.nativeElement.querySelectorAll('.selection-card');
|
||||
expect(cards.length).toBe(5);
|
||||
});
|
||||
|
||||
it('treats a config missing the instances field as unconfigured', () => {
|
||||
const configWithoutInstances = { failedImportMaxStrikes: -1 } as ArrConfig;
|
||||
const api = createApi([
|
||||
configWithoutInstances, RADARR_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG,
|
||||
]);
|
||||
const { component } = setup(api);
|
||||
|
||||
expect(component.configured().map((e) => e.type)).toEqual(['radarr']);
|
||||
expect(component.unconfigured()).toContain('sonarr');
|
||||
});
|
||||
|
||||
it('shows the experimental badge on the lazylibrarian selection card only', () => {
|
||||
const { fixture } = setup();
|
||||
|
||||
const cards = Array.from(fixture.nativeElement.querySelectorAll('.selection-card')) as HTMLElement[];
|
||||
const lazyLibrarianCard = cards.find((el) => el.textContent!.includes('LazyLibrarian'))!;
|
||||
const otherCards = cards.filter((el) => el !== lazyLibrarianCard);
|
||||
|
||||
expect(lazyLibrarianCard.querySelector('app-badge')).not.toBeNull();
|
||||
expect(lazyLibrarianCard.textContent).toContain('Experimental');
|
||||
otherCards.forEach((card) => expect(card.querySelector('app-badge')).toBeNull());
|
||||
});
|
||||
|
||||
it('scrolls to the fragment from the route once settings load', async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
(Element.prototype as unknown as { scrollIntoView: typeof scrollIntoView }).scrollIntoView = scrollIntoView;
|
||||
|
||||
try {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ArrApi, useValue: createApi() },
|
||||
{ provide: ToastService, useValue: createToast() },
|
||||
{ provide: ThemeService, useValue: { themedIconSrc: (src: string) => src } },
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { fragment: 'radarr' } } },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(ArrAppsComponent);
|
||||
fixture.detectChanges();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' });
|
||||
} finally {
|
||||
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView;
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the loading state while the initial config request is pending', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pending = new Subject<ArrConfig>();
|
||||
const api = createApi();
|
||||
api.getConfig.mockReturnValue(pending);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ArrApi, useValue: api },
|
||||
{ provide: ToastService, useValue: createToast() },
|
||||
{ provide: ThemeService, useValue: { themedIconSrc: (src: string) => src } },
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { fragment: null } } },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(ArrAppsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.configured()).toEqual([]);
|
||||
expect(fixture.componentInstance.unconfigured()).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-loading-state')).not.toBeNull();
|
||||
|
||||
pending.next(EMPTY_CONFIG);
|
||||
pending.complete();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries the load when the Retry button is clicked', () => {
|
||||
const api = createApi();
|
||||
api.getConfig.mockReturnValue(throwError(() => new Error('offline')));
|
||||
const { fixture } = setup(api);
|
||||
|
||||
api.getConfig.mockReturnValue(of(EMPTY_CONFIG));
|
||||
const retryButton = Array.from(fixture.nativeElement.querySelectorAll('button'))
|
||||
.find((el) => (el as HTMLElement).textContent!.trim() === 'Retry') as HTMLButtonElement;
|
||||
retryButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.loadError()).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the "Add another app" card when every app is configured', () => {
|
||||
const api = createApi([
|
||||
SONARR_CONFIG, RADARR_CONFIG, SONARR_CONFIG, SONARR_CONFIG, SONARR_CONFIG, SONARR_CONFIG, SONARR_CONFIG,
|
||||
]);
|
||||
const { fixture } = setup(api);
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.list-header__title')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows empty state when no apps are configured', () => {
|
||||
const api = createApi([
|
||||
EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG,
|
||||
]);
|
||||
const { fixture: f } = setup(api);
|
||||
|
||||
expect(f.nativeElement.textContent).toContain('No media managers yet');
|
||||
expect(f.nativeElement.textContent).toContain('Pick an app below to connect it.');
|
||||
});
|
||||
|
||||
it('opens add modal for selected unconfigured app type', () => {
|
||||
const { fixture, component } = setup();
|
||||
|
||||
component.openAddModal('lidarr');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.modalVisible()).toBe(true);
|
||||
expect(component.modalType()).toBe('lidarr');
|
||||
expect(component.editingInstance()).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Add Lidarr Instance');
|
||||
});
|
||||
|
||||
it('sets version to first option when opening add modal', () => {
|
||||
const { component } = setup();
|
||||
|
||||
component.openAddModal('whisparr');
|
||||
|
||||
expect(component.instanceModel().version).toBe(2);
|
||||
});
|
||||
|
||||
it('creates an instance and reloads the page', () => {
|
||||
const { fixture, component, api, toast } = setup();
|
||||
|
||||
component.openAddModal('sonarr');
|
||||
component.instanceForm.name().value.set('New Sonarr');
|
||||
component.instanceForm.url().value.set('http://localhost:8989');
|
||||
component.instanceForm.apiKey().value.set('key');
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.createInstance).toHaveBeenCalledWith('sonarr', {
|
||||
name: 'New Sonarr',
|
||||
url: 'http://localhost:8989',
|
||||
externalUrl: undefined,
|
||||
apiKey: 'key',
|
||||
version: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Instance added');
|
||||
expect(component.modalVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('opens edit modal for a selected instance', () => {
|
||||
const { fixture, component } = setup();
|
||||
|
||||
component.openEditModal('sonarr', SONARR_INSTANCE);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.modalVisible()).toBe(true);
|
||||
expect(component.modalType()).toBe('sonarr');
|
||||
expect(component.editingInstance()).toBe(SONARR_INSTANCE);
|
||||
expect(fixture.nativeElement.textContent).toContain('Edit Sonarr Instance');
|
||||
expect(component.instanceModel().name).toBe('Main Sonarr');
|
||||
});
|
||||
|
||||
it('defaults externalUrl to an empty string when the instance has none', () => {
|
||||
const { component } = setup();
|
||||
|
||||
component.openEditModal('radarr', RADARR_INSTANCE);
|
||||
|
||||
expect(component.instanceModel().externalUrl).toBe('');
|
||||
});
|
||||
|
||||
it('keeps a non-default version when editing after another type', () => {
|
||||
const { fixture, component } = setup();
|
||||
const whisparrV3: ArrInstance = { ...SONARR_INSTANCE, id: 'whisparr-1', version: 3 };
|
||||
|
||||
component.openEditModal('sonarr', SONARR_INSTANCE);
|
||||
fixture.detectChanges();
|
||||
component.modalVisible.set(false);
|
||||
component.openEditModal('whisparr', whisparrV3);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.instanceModel().version).toBe(3);
|
||||
});
|
||||
|
||||
it('updates an instance by id', () => {
|
||||
const { fixture, component, api, toast } = setup();
|
||||
|
||||
component.openEditModal('sonarr', SONARR_INSTANCE);
|
||||
component.instanceForm.name().value.set('Renamed');
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateInstance).toHaveBeenCalledWith('sonarr', 'sonarr-1', {
|
||||
name: 'Renamed',
|
||||
url: 'http://localhost:8989',
|
||||
externalUrl: 'https://sonarr.example.com',
|
||||
apiKey: 'sonarr-key',
|
||||
version: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Instance updated');
|
||||
});
|
||||
|
||||
it('tests connection with instance id when editing', () => {
|
||||
const { fixture, component, api } = setup();
|
||||
|
||||
component.openEditModal('sonarr', SONARR_INSTANCE);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.testConnection();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.testInstance).toHaveBeenCalledWith('sonarr', {
|
||||
url: 'http://localhost:8989',
|
||||
apiKey: 'sonarr-key',
|
||||
version: 4,
|
||||
instanceId: 'sonarr-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a default success message when the API returns none', () => {
|
||||
const api = createApi();
|
||||
api.testInstance.mockReturnValue(of({ message: '' }));
|
||||
const { fixture, component, toast } = setup(api);
|
||||
|
||||
component.openAddModal('sonarr');
|
||||
fixture.detectChanges();
|
||||
|
||||
component.testConnection();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('Connection successful');
|
||||
});
|
||||
|
||||
it('tests connection without instance id when adding', () => {
|
||||
const { fixture, component, api } = setup();
|
||||
|
||||
component.openAddModal('radarr');
|
||||
fixture.detectChanges();
|
||||
|
||||
component.testConnection();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.testInstance).toHaveBeenCalledWith('radarr', {
|
||||
url: '',
|
||||
apiKey: '',
|
||||
version: 6,
|
||||
instanceId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call the API when deleting an instance without an id', async () => {
|
||||
const { component, api } = setup();
|
||||
const instanceWithoutId: ArrInstance = { ...SONARR_INSTANCE, id: '' };
|
||||
|
||||
await component.deleteInstance('sonarr', instanceWithoutId);
|
||||
|
||||
expect(api.deleteInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an instance after confirmation', async () => {
|
||||
const { fixture, component, api, toast, confirm } = setup();
|
||||
|
||||
const cancelled = component.deleteInstance('sonarr', SONARR_INSTANCE);
|
||||
confirm.cancel();
|
||||
await cancelled;
|
||||
|
||||
expect(api.deleteInstance).not.toHaveBeenCalled();
|
||||
|
||||
const accepted = component.deleteInstance('sonarr', SONARR_INSTANCE);
|
||||
confirm.accept();
|
||||
await accepted;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.deleteInstance).toHaveBeenCalledWith('sonarr', 'sonarr-1');
|
||||
expect(toast.success).toHaveBeenCalledWith('Instance deleted');
|
||||
});
|
||||
|
||||
it('reports pending changes while modal is edited', () => {
|
||||
const { fixture, component } = setup();
|
||||
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
|
||||
component.openAddModal('sonarr');
|
||||
fixture.detectChanges();
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
|
||||
component.instanceForm.name().value.set('New');
|
||||
fixture.detectChanges();
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
|
||||
component.instanceForm.url().value.set('http://localhost:8989');
|
||||
component.instanceForm.apiKey().value.set('key');
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an arr-section instance mounted and its unsaved edits while a delete reload is in flight', async () => {
|
||||
const api = createApi();
|
||||
const { fixture, component, confirm } = setup(api);
|
||||
|
||||
const sectionBefore = component.sections()[0];
|
||||
const sectionElBefore = fixture.nativeElement.querySelectorAll('app-arr-section')[0];
|
||||
sectionBefore.failedImportForm.override().value.set(true);
|
||||
sectionBefore.failedImportForm.maxStrikes().value.set(9);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pending = new Subject<ArrConfig>();
|
||||
api.getConfig.mockReturnValue(pending);
|
||||
|
||||
const deleted = component.deleteInstance('radarr', RADARR_INSTANCE);
|
||||
confirm.accept();
|
||||
await deleted;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.loader.loading()).toBe(true);
|
||||
expect(fixture.nativeElement.querySelectorAll('app-arr-section')[0]).toBe(sectionElBefore);
|
||||
expect(component.sections()[0]).toBe(sectionBefore);
|
||||
|
||||
pending.next(SONARR_CONFIG);
|
||||
pending.complete();
|
||||
fixture.detectChanges();
|
||||
|
||||
const sectionAfter = component.sections()[0];
|
||||
expect(sectionAfter).toBe(sectionBefore);
|
||||
expect(sectionAfter.failedImportModel()).toEqual({ override: true, maxStrikes: 9 });
|
||||
});
|
||||
|
||||
it('reports pending changes from arr-section components', () => {
|
||||
const { component } = setup();
|
||||
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
|
||||
const sections = component.sections();
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
vi.spyOn(sections[0], 'hasPendingChanges').mockReturnValue(true);
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows connection error state and recovers on retry', () => {
|
||||
const api = createApi();
|
||||
api.getConfig.mockReturnValue(throwError(() => new Error('offline')));
|
||||
const { fixture, component, toast } = setup(api);
|
||||
|
||||
expect(component.loadError()).toBe(true);
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to load media manager settings: offline');
|
||||
expect(fixture.nativeElement.textContent).toContain('Could not connect to server');
|
||||
|
||||
api.getConfig.mockReturnValue(of(EMPTY_CONFIG));
|
||||
component.retry();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.loadError()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps modal open and reports error when save fails', () => {
|
||||
const api = createApi();
|
||||
api.createInstance.mockReturnValue(throwError(() => ({ message: 'Name exists' })));
|
||||
const { fixture: f, component, toast } = setup(api);
|
||||
|
||||
component.openAddModal('sonarr');
|
||||
component.instanceForm.name().value.set('New');
|
||||
component.instanceForm.url().value.set('http://localhost:8989');
|
||||
component.instanceForm.apiKey().value.set('key');
|
||||
f.detectChanges();
|
||||
|
||||
component.saveInstance();
|
||||
f.detectChanges();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Name exists');
|
||||
expect(component.modalVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not call the API when saving an invalid form', () => {
|
||||
const { component, api } = setup();
|
||||
|
||||
component.openAddModal('sonarr');
|
||||
|
||||
component.saveInstance();
|
||||
|
||||
expect(api.createInstance).not.toHaveBeenCalled();
|
||||
expect(api.updateInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toasts the error when deleting an instance fails', async () => {
|
||||
const api = createApi();
|
||||
api.deleteInstance.mockReturnValue(throwError(() => ({ message: 'Delete failed' })));
|
||||
const { component, toast, confirm } = setup(api);
|
||||
|
||||
const deleted = component.deleteInstance('sonarr', SONARR_INSTANCE);
|
||||
confirm.accept();
|
||||
await deleted;
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Delete failed');
|
||||
});
|
||||
|
||||
it('shows error when test connection fails', () => {
|
||||
const api = createApi();
|
||||
api.testInstance.mockReturnValue(throwError(() => ({ message: 'Connection refused' })));
|
||||
const { fixture: f, component, toast } = setup(api);
|
||||
|
||||
component.openAddModal('sonarr');
|
||||
f.detectChanges();
|
||||
|
||||
component.testConnection();
|
||||
f.detectChanges();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Connection refused');
|
||||
expect(component.testing()).toBe(false);
|
||||
});
|
||||
|
||||
it('emits from arr-section when add is clicked', () => {
|
||||
const { component } = setup();
|
||||
|
||||
const sections = component.sections();
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
const openAddSpy = vi.spyOn(component, 'openAddModal');
|
||||
sections[0].onAddClick();
|
||||
expect(openAddSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits from arr-section when edit is clicked', () => {
|
||||
const { component } = setup();
|
||||
|
||||
const sections = component.sections();
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
const openEditSpy = vi.spyOn(component, 'openEditModal');
|
||||
sections[0].onEditClick(SONARR_INSTANCE);
|
||||
expect(openEditSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits from arr-section when delete is clicked', () => {
|
||||
const { component } = setup();
|
||||
|
||||
const sections = component.sections();
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
const deleteSpy = vi.spyOn(component, 'deleteInstance');
|
||||
sections[0].onDeleteClick(SONARR_INSTANCE);
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables save button when form has errors', () => {
|
||||
const { fixture, component } = setup();
|
||||
|
||||
component.openAddModal('sonarr');
|
||||
fixture.detectChanges();
|
||||
|
||||
const saveButton = Array.from(fixture.nativeElement.querySelectorAll('[modal-footer] button'))
|
||||
.find((el) => (el as HTMLElement).textContent!.trim() === 'Save') as HTMLButtonElement | undefined;
|
||||
expect(saveButton).toBeDefined();
|
||||
expect(saveButton!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('validates required fields', () => {
|
||||
const { fixture, component } = setup();
|
||||
|
||||
component.openAddModal('sonarr');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.instanceForm.name().errors()[0]?.message).toBe('Name is required');
|
||||
expect(component.instanceForm.url().errors()[0]?.message).toBe('URL is required');
|
||||
expect(component.instanceForm.apiKey().errors()[0]?.message).toBe('API key is required');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, viewChildren,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { form, required, FormField } from '@angular/forms/signals';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
|
||||
SelectComponent, ModalComponent, EmptyStateComponent, BadgeComponent, LoadingStateComponent,
|
||||
} from '@ui';
|
||||
import { ArrApi } from '@core/api/arr.api';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ThemeService } from '@core/services/theme.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '@shared/models/arr-config.model';
|
||||
import type { ArrType } from '@shared/models/enums';
|
||||
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
|
||||
import { createSettingsResource } from '@shared/utils/settings-resource.util';
|
||||
import { ARR_TYPES, ARR_APP_META } from './arr-app-meta';
|
||||
import { ArrSectionComponent } from './arr-section.component';
|
||||
|
||||
interface ArrInstanceFormModel {
|
||||
name: string;
|
||||
url: string;
|
||||
externalUrl: string;
|
||||
apiKey: string;
|
||||
version: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-arr-apps',
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent, ToggleComponent,
|
||||
SelectComponent, ModalComponent, EmptyStateComponent, BadgeComponent, LoadingStateComponent,
|
||||
FormField, ArrSectionComponent,
|
||||
],
|
||||
templateUrl: './arr-apps.component.html',
|
||||
styleUrl: './arr-apps.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ArrAppsComponent implements HasPendingChanges {
|
||||
private readonly api = inject(ArrApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
|
||||
protected readonly ARR_APP_META = ARR_APP_META;
|
||||
|
||||
private readonly settings = createSettingsResource({
|
||||
params: () => true,
|
||||
load: () => forkJoin(ARR_TYPES.map(type =>
|
||||
this.api.getConfig(type),
|
||||
)),
|
||||
errorMessage: 'Failed to load media manager settings',
|
||||
});
|
||||
readonly configResource = this.settings.resource;
|
||||
|
||||
readonly loader = this.settings.loader;
|
||||
readonly loadError = this.settings.loadError;
|
||||
|
||||
private readonly allConfigs = computed(() => {
|
||||
if (!this.configResource.hasValue()) {
|
||||
return [];
|
||||
}
|
||||
const configs = this.configResource.value();
|
||||
return ARR_TYPES.map((type, index) => ({
|
||||
type,
|
||||
config: configs[index],
|
||||
}));
|
||||
});
|
||||
|
||||
readonly configured = computed(() =>
|
||||
this.allConfigs().filter(e => (e.config.instances ?? []).length > 0),
|
||||
);
|
||||
|
||||
readonly unconfigured = computed(() =>
|
||||
this.allConfigs().filter(e => (e.config.instances ?? []).length === 0).map(e => e.type),
|
||||
);
|
||||
|
||||
readonly sections = viewChildren(ArrSectionComponent);
|
||||
|
||||
// Modal state
|
||||
readonly modalType = signal<ArrType>('sonarr');
|
||||
readonly modalVisible = signal(false);
|
||||
readonly editingInstance = signal<ArrInstance | null>(null);
|
||||
readonly testing = signal(false);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly modalMeta = computed(() => ARR_APP_META[this.modalType()]);
|
||||
readonly modalDisplayName = computed(() => this.modalMeta().displayName);
|
||||
readonly modalIsEdit = computed(() => !!this.editingInstance());
|
||||
|
||||
readonly instanceModel = signal<ArrInstanceFormModel>({
|
||||
name: '', url: '', externalUrl: '', apiKey: '', version: 3, enabled: true,
|
||||
});
|
||||
readonly instanceForm = form(this.instanceModel, (p) => {
|
||||
required(p.name, { message: 'Name is required' });
|
||||
required(p.url, { message: 'URL is required' });
|
||||
required(p.apiKey, { message: 'API key is required' });
|
||||
});
|
||||
|
||||
readonly hasModalErrors = computed(() => this.instanceForm().invalid());
|
||||
|
||||
private readonly openSnapshot = signal('');
|
||||
/** Reloads after a save must not yank the page back to the deep-linked section. */
|
||||
private fragmentScrolled = false;
|
||||
private readonly modalDirty = computed(() =>
|
||||
this.modalVisible() && JSON.stringify(this.instanceModel()) !== this.openSnapshot());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.fragmentScrolled && !this.configResource.isLoading() && this.configResource.hasValue()) {
|
||||
this.fragmentScrolled = true;
|
||||
const fragment = untracked(() => this.route.snapshot.fragment);
|
||||
if (fragment) {
|
||||
queueMicrotask(() => {
|
||||
document.getElementById(fragment)?.scrollIntoView({ block: 'start' });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.settings.retry();
|
||||
}
|
||||
|
||||
openAddModal(type: ArrType): void {
|
||||
this.modalType.set(type);
|
||||
this.editingInstance.set(null);
|
||||
const options = ARR_APP_META[type].versionOptions;
|
||||
this.instanceModel.set({
|
||||
name: '', url: '', externalUrl: '', apiKey: '',
|
||||
version: options.length > 0 ? (options[0].value as number) : 3,
|
||||
enabled: true,
|
||||
});
|
||||
this.openSnapshot.set(JSON.stringify(this.instanceModel()));
|
||||
this.modalVisible.set(true);
|
||||
}
|
||||
|
||||
openEditModal(type: ArrType, instance: ArrInstance): void {
|
||||
this.modalType.set(type);
|
||||
this.editingInstance.set(instance);
|
||||
this.instanceModel.set({
|
||||
name: instance.name,
|
||||
url: instance.url,
|
||||
externalUrl: instance.externalUrl ?? '',
|
||||
apiKey: instance.apiKey,
|
||||
version: instance.version,
|
||||
enabled: instance.enabled,
|
||||
});
|
||||
this.openSnapshot.set(JSON.stringify(this.instanceModel()));
|
||||
this.modalVisible.set(true);
|
||||
}
|
||||
|
||||
testConnection(): void {
|
||||
const m = this.instanceModel();
|
||||
const request: TestArrInstanceRequest = {
|
||||
url: m.url,
|
||||
apiKey: m.apiKey,
|
||||
version: m.version ?? 3,
|
||||
instanceId: this.editingInstance()?.id,
|
||||
};
|
||||
this.testing.set(true);
|
||||
this.api.testInstance(this.modalType(), request).subscribe({
|
||||
next: (result) => {
|
||||
this.toast.success(result.message || 'Connection successful');
|
||||
this.testing.set(false);
|
||||
},
|
||||
error: (err: ApiError) => {
|
||||
this.toast.error(err.message);
|
||||
this.testing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
saveInstance(): void {
|
||||
if (this.instanceForm().invalid()) {
|
||||
return;
|
||||
}
|
||||
const m = this.instanceModel();
|
||||
const dto: CreateArrInstanceDto = {
|
||||
name: m.name,
|
||||
url: m.url,
|
||||
externalUrl: m.externalUrl || undefined,
|
||||
apiKey: m.apiKey,
|
||||
version: m.version ?? 3,
|
||||
enabled: m.enabled,
|
||||
};
|
||||
|
||||
this.saving.set(true);
|
||||
const editing = this.editingInstance();
|
||||
const obs = editing?.id
|
||||
? this.api.updateInstance(this.modalType(), editing.id, dto)
|
||||
: this.api.createInstance(this.modalType(), dto);
|
||||
|
||||
obs.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(editing ? 'Instance updated' : 'Instance added');
|
||||
this.modalVisible.set(false);
|
||||
this.saving.set(false);
|
||||
this.configResource.reload();
|
||||
},
|
||||
error: (err: ApiError) => {
|
||||
this.toast.error(err.message);
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstance(type: ArrType, instance: ArrInstance): Promise<void> {
|
||||
if (!instance.id) return;
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Delete Instance',
|
||||
message: `Are you sure you want to delete "${instance.name}"? This action cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.api.deleteInstance(type, instance.id).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Instance deleted');
|
||||
this.configResource.reload();
|
||||
},
|
||||
error: (err: ApiError) => this.toast.error(err.message),
|
||||
});
|
||||
}
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return this.modalDirty() || this.sections().some(s => s.hasPendingChanges());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<app-card>
|
||||
<div class="section-header">
|
||||
<div class="section-header__title">
|
||||
<img [src]="themedIconSrc(meta().iconSrc)" alt="" aria-hidden="true" class="section-header__icon" />
|
||||
<h3 class="section-header__name">{{ meta().displayName }} instances</h3>
|
||||
@if (meta().experimental) {
|
||||
<app-badge severity="warning" size="sm">Experimental</app-badge>
|
||||
}
|
||||
</div>
|
||||
<div class="section-header__actions">
|
||||
<app-button variant="ghost" size="sm" [ariaExpanded]="panelVisible()" (clicked)="toggleStrikesPanel()">
|
||||
<ng-icon name="tablerAdjustmentsHorizontal" size="16" />
|
||||
Strikes
|
||||
</app-button>
|
||||
<app-button variant="primary" size="sm" (clicked)="onAddClick()">Add</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (panelVisible()) {
|
||||
<div class="strikes-panel">
|
||||
<div class="form-stack">
|
||||
<app-toggle label="Override global max strikes" [formField]="failedImportForm.override"
|
||||
hint="Use a different failed import max strikes value than the queue cleaner setting"
|
||||
helpKey="arr:failedImportOverride" />
|
||||
@if (failedImportForm.override().value()) {
|
||||
<app-number-input label="Max Strikes" [formField]="failedImportForm.maxStrikes"
|
||||
hint="Number of strikes before action is taken (0 to disable for this app, min 3 to enable)"
|
||||
[error]="failedImportForm.maxStrikes().errors()[0]?.message"
|
||||
helpKey="arr:failedImportMaxStrikes" />
|
||||
}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<app-button variant="primary" [glowing]="failedImportDirty()" [loading]="failedImportSaving()"
|
||||
[disabled]="reloading() || failedImportSaving() || failedImportSaved() || failedImportHasErrors() || !failedImportDirty()"
|
||||
(clicked)="saveFailedImport()">
|
||||
{{ failedImportSaved() ? 'Saved!' : 'Save Settings' }}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (instance of instances(); track instance.id) {
|
||||
<div class="item-row">
|
||||
<div class="item-row__info">
|
||||
<span class="item-row__name">{{ instance.name }}</span>
|
||||
<app-badge [severity]="instance.enabled ? 'success' : 'default'" size="sm">
|
||||
{{ instance.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</app-badge>
|
||||
<span class="item-row__url">{{ instance.url }}</span>
|
||||
</div>
|
||||
<div class="item-row__actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="onEditClick(instance)">Edit</app-button>
|
||||
<app-button variant="destructive" size="sm" (clicked)="onDeleteClick(instance)">Delete</app-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</app-card>
|
||||
@@ -0,0 +1,58 @@
|
||||
@use 'settings-layout' as *;
|
||||
@use 'list-layout' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@include list-header;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.strikes-panel {
|
||||
padding: var(--space-4) 0;
|
||||
margin-bottom: var(--space-2);
|
||||
border-top: 1px solid var(--divider);
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.form-stack { @include form-stack; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.item-row {
|
||||
@include item-row;
|
||||
|
||||
&__url {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component, input } from '@angular/core';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { ArrApi } from '@core/api/arr.api';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ThemeService } from '@core/services/theme.service';
|
||||
import { ArrConfig, ArrInstance } from '@shared/models/arr-config.model';
|
||||
import type { ArrType } from '@shared/models/enums';
|
||||
import { ArrSectionComponent } from './arr-section.component';
|
||||
|
||||
const INSTANCE: ArrInstance = {
|
||||
id: 'instance-1',
|
||||
enabled: true,
|
||||
name: 'Main Sonarr',
|
||||
url: 'http://localhost:8989',
|
||||
externalUrl: 'https://sonarr.example.com',
|
||||
apiKey: 'abc123',
|
||||
version: 4,
|
||||
};
|
||||
|
||||
const CONFIG: ArrConfig = {
|
||||
failedImportMaxStrikes: 3,
|
||||
instances: [INSTANCE],
|
||||
};
|
||||
|
||||
function createApi() {
|
||||
return {
|
||||
updateConfig: vi.fn(() => of(undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
function createToast() {
|
||||
return { success: vi.fn(), error: vi.fn() };
|
||||
}
|
||||
|
||||
function createTheme() {
|
||||
return {
|
||||
themedIconSrc: vi.fn((src: string) => src),
|
||||
};
|
||||
}
|
||||
|
||||
interface Setup {
|
||||
fixture: ComponentFixture<HostComponent>;
|
||||
component: ArrSectionComponent;
|
||||
api: ReturnType<typeof createApi>;
|
||||
toast: ReturnType<typeof createToast>;
|
||||
theme: ReturnType<typeof createTheme>;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [ArrSectionComponent],
|
||||
template: `
|
||||
<app-arr-section
|
||||
[type]="type()"
|
||||
[config]="config()"
|
||||
[reloading]="reloading()"
|
||||
(add)="onAdd()"
|
||||
(edit)="onEdit($event)"
|
||||
(delete)="onDelete($event)"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
class HostComponent {
|
||||
type = input.required<ArrType>();
|
||||
config = input.required<ArrConfig>();
|
||||
reloading = input(false);
|
||||
onAdd = vi.fn();
|
||||
onEdit = vi.fn();
|
||||
onDelete = vi.fn();
|
||||
}
|
||||
|
||||
function text(fixture: ComponentFixture<HostComponent>, selector: string): string[] {
|
||||
return Array.from(fixture.nativeElement.querySelectorAll(selector)).map((el) =>
|
||||
(el as HTMLElement).textContent!.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
describe('ArrSectionComponent', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setup(
|
||||
type: ArrType = 'sonarr', config = CONFIG, api = createApi(), toast = createToast(), theme = createTheme(),
|
||||
): Setup {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ArrApi, useValue: api },
|
||||
{ provide: ToastService, useValue: toast },
|
||||
{ provide: ThemeService, useValue: theme },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.componentRef.setInput('type', type);
|
||||
fixture.componentRef.setInput('config', config);
|
||||
fixture.detectChanges();
|
||||
|
||||
return {
|
||||
fixture,
|
||||
component: fixture.debugElement.query((el) => el.name === 'app-arr-section').componentInstance,
|
||||
api,
|
||||
toast,
|
||||
theme,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders instances from the config input', () => {
|
||||
const { fixture, component } = setup('sonarr');
|
||||
|
||||
expect(component.instances()).toEqual([INSTANCE]);
|
||||
expect(text(fixture, '.item-row__name')).toEqual(['Main Sonarr']);
|
||||
expect(text(fixture, '.item-row__url')).toEqual(['http://localhost:8989']);
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a config with no instances field as having none', () => {
|
||||
const configWithoutInstances = { failedImportMaxStrikes: -1 } as ArrConfig;
|
||||
const { component } = setup('sonarr', configWithoutInstances);
|
||||
|
||||
expect(component.instances()).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows a disabled badge for a disabled instance', () => {
|
||||
const disabledInstance: ArrInstance = { ...INSTANCE, enabled: false };
|
||||
const { fixture } = setup('sonarr', { failedImportMaxStrikes: 3, instances: [disabledInstance] });
|
||||
|
||||
expect(text(fixture, '.item-row__info app-badge')).toEqual(['Disabled']);
|
||||
});
|
||||
|
||||
it('emits add output when Add button is clicked', () => {
|
||||
const { fixture, component } = setup('sonarr');
|
||||
const hostComponent = fixture.componentInstance;
|
||||
|
||||
component.onAddClick();
|
||||
|
||||
expect(hostComponent.onAdd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits edit output when Edit button is clicked', () => {
|
||||
const { fixture, component } = setup('sonarr');
|
||||
|
||||
component.onEditClick(INSTANCE);
|
||||
|
||||
expect(fixture.componentInstance.onEdit).toHaveBeenCalledWith(INSTANCE);
|
||||
});
|
||||
|
||||
it('emits delete output when Delete button is clicked', () => {
|
||||
const { fixture, component } = setup('sonarr');
|
||||
const hostComponent = fixture.componentInstance;
|
||||
|
||||
component.onDeleteClick(INSTANCE);
|
||||
|
||||
expect(hostComponent.onDelete).toHaveBeenCalledWith(INSTANCE);
|
||||
});
|
||||
|
||||
it('shows the experimental badge only for an experimental arr type', () => {
|
||||
const { fixture } = setup('lazylibrarian', { ...CONFIG, instances: [] });
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.section-header__title app-badge')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Experimental');
|
||||
});
|
||||
|
||||
it('hides the experimental badge for a non-experimental arr type', () => {
|
||||
const { fixture } = setup('sonarr');
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.section-header__title app-badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('themes the header icon through the theme service', () => {
|
||||
const { theme } = setup('sonarr');
|
||||
|
||||
expect(theme.themedIconSrc).toHaveBeenCalledWith('icons/ext/sonarr-light.svg');
|
||||
});
|
||||
|
||||
it('shows the max strikes input only while the override is on', () => {
|
||||
const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
component.strikesOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-number-input')).toBeNull();
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-number-input')).not.toBeNull();
|
||||
|
||||
component.failedImportForm.override().value.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-number-input')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets host id to the arr type', () => {
|
||||
const { fixture } = setup('radarr');
|
||||
|
||||
const hostEl = fixture.nativeElement.querySelector('app-arr-section');
|
||||
expect(hostEl.id).toBe('radarr');
|
||||
});
|
||||
|
||||
it('shows the strikes panel only when strikesOpen, dirty, or hasErrors is true', () => {
|
||||
const { fixture, component } = setup('sonarr');
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.strikes-panel')).toBeNull();
|
||||
expect(component.panelVisible()).toBe(false);
|
||||
|
||||
component.strikesOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.strikes-panel')).not.toBeNull();
|
||||
expect(component.panelVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('reflects panelVisible on the strikes toggle button aria-expanded attribute', () => {
|
||||
const { fixture } = setup('sonarr');
|
||||
|
||||
const strikesButton = Array.from(fixture.nativeElement.querySelectorAll('.section-header__actions button'))
|
||||
.find((el) => (el as HTMLElement).textContent!.includes('Strikes')) as HTMLButtonElement;
|
||||
expect(strikesButton.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
strikesButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(strikesButton.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('seeds the override toggle off with the config failedImportMaxStrikes of -1', () => {
|
||||
const { component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
expect(component.failedImportModel()).toEqual({ override: false, maxStrikes: 3 });
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('seeds the override toggle on with the config failedImportMaxStrikes value', () => {
|
||||
const { component } = setup('sonarr', { failedImportMaxStrikes: 5, instances: [] });
|
||||
|
||||
expect(component.failedImportModel()).toEqual({ override: true, maxStrikes: 5 });
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('enables the override, sets a value and saves it', () => {
|
||||
const { fixture, component, api, toast } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
component.failedImportForm.maxStrikes().value.set(5);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
|
||||
component.saveFailedImport();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: 5 });
|
||||
expect(toast.success).toHaveBeenCalledWith('Failed import settings saved');
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('saves via the Save Settings button click and shows the Saved! label', () => {
|
||||
const { fixture, component, api } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
component.strikesOpen.set(true);
|
||||
component.failedImportForm.override().value.set(true);
|
||||
component.failedImportForm.maxStrikes().value.set(5);
|
||||
fixture.detectChanges();
|
||||
|
||||
const saveButton = fixture.nativeElement.querySelector('.form-actions button') as HTMLButtonElement;
|
||||
saveButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: 5 });
|
||||
expect(saveButton.textContent!.trim()).toBe('Saved!');
|
||||
});
|
||||
|
||||
it('disables Save Settings while the parent reloads', () => {
|
||||
const { fixture, component } = setup();
|
||||
|
||||
component.strikesOpen.set(true);
|
||||
component.failedImportForm.maxStrikes().value.set(9);
|
||||
fixture.componentRef.setInput('reloading', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const saveButton = fixture.nativeElement.querySelector('.form-actions button') as HTMLButtonElement;
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('reloading', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(saveButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disabling the override sends -1', () => {
|
||||
const { fixture, component, api } = setup('sonarr', { failedImportMaxStrikes: 5, instances: [] });
|
||||
|
||||
component.failedImportForm.override().value.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveFailedImport();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: -1 });
|
||||
});
|
||||
|
||||
it('rejects 1 and 2 strikes, accepts 0, and disables save while invalid', () => {
|
||||
const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(1);
|
||||
fixture.detectChanges();
|
||||
expect(component.failedImportHasErrors()).toBe(true);
|
||||
expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe('Minimum value is 3');
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(2);
|
||||
fixture.detectChanges();
|
||||
expect(component.failedImportHasErrors()).toBe(true);
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(0);
|
||||
fixture.detectChanges();
|
||||
expect(component.failedImportHasErrors()).toBe(false);
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(1);
|
||||
fixture.detectChanges();
|
||||
const saveButton = fixture.nativeElement.querySelector('.form-actions button') as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects fractional failed import strikes', () => {
|
||||
const { fixture, component, api } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(3.5);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe('Value must be a whole number');
|
||||
expect(component.failedImportHasErrors()).toBe(true);
|
||||
|
||||
component.saveFailedImport();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, 'This field is required'],
|
||||
[-5, 'Value cannot be negative'],
|
||||
[5001, 'Value cannot exceed 5000'],
|
||||
])('rejects a failed import maxStrikes of %s with "%s"', (maxStrikes, message) => {
|
||||
const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(maxStrikes);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe(message);
|
||||
});
|
||||
|
||||
it('accepts a failed import maxStrikes of 5000', () => {
|
||||
const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(5000);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportForm.maxStrikes().errors().length).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores an invalid failed import maxStrikes while the override is off', () => {
|
||||
const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportForm.maxStrikes().errors().length).toBe(0);
|
||||
expect(component.failedImportHasErrors()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays clean when the override is toggled back off after editing the hidden value', () => {
|
||||
const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] });
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
component.failedImportForm.maxStrikes().value.set(9999);
|
||||
fixture.detectChanges();
|
||||
component.failedImportForm.override().value.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
expect(component.failedImportHasErrors()).toBe(false);
|
||||
});
|
||||
|
||||
it('toasts the error when saving the failed import override fails', () => {
|
||||
const api = createApi();
|
||||
api.updateConfig.mockReturnValue(throwError(() => new ApiError('Save failed')));
|
||||
const toast = createToast();
|
||||
const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }, api, toast);
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveFailedImport();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Save failed');
|
||||
expect(component.failedImportSaving()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps unsaved failed import edits when the config input changes with same type', () => {
|
||||
const api = createApi();
|
||||
const { fixture, component } = setup('sonarr', CONFIG, api);
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(9);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentRef.setInput('config', { ...CONFIG, failedImportMaxStrikes: 7 });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportModel().maxStrikes).toBe(9);
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
});
|
||||
|
||||
it('reseeds the failed import settings when the type input changes despite unsaved edits', () => {
|
||||
const { fixture, component } = setup('sonarr');
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(9);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentRef.setInput('type', 'radarr' as ArrType);
|
||||
fixture.componentRef.setInput('config', { ...CONFIG, failedImportMaxStrikes: -1 });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportModel()).toEqual({ override: false, maxStrikes: 3 });
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, inject, signal, input, output, computed, effect, untracked,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { form, validate, FormField } from '@angular/forms/signals';
|
||||
import {
|
||||
CardComponent, ButtonComponent, NumberInputComponent, ToggleComponent, BadgeComponent,
|
||||
} from '@ui';
|
||||
import { ArrApi } from '@core/api/arr.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ThemeService } from '@core/services/theme.service';
|
||||
import { ArrInstance, ArrConfig } from '@shared/models/arr-config.model';
|
||||
import type { ArrType } from '@shared/models/enums';
|
||||
import { createDirtyTracker } from '@shared/utils/dirty-tracker.util';
|
||||
import { saveSettings } from '@shared/utils/settings-resource.util';
|
||||
import { ARR_APP_META } from './arr-app-meta';
|
||||
|
||||
interface FailedImportFormModel {
|
||||
override: boolean;
|
||||
maxStrikes: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-arr-section',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent, ButtonComponent, NumberInputComponent, ToggleComponent, BadgeComponent,
|
||||
NgIcon, FormField,
|
||||
],
|
||||
host: {
|
||||
'[attr.id]': 'type()',
|
||||
},
|
||||
templateUrl: './arr-section.component.html',
|
||||
styleUrl: './arr-section.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ArrSectionComponent {
|
||||
private readonly api = inject(ArrApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
|
||||
readonly type = input.required<ArrType>();
|
||||
readonly config = input.required<ArrConfig>();
|
||||
readonly reloading = input(false);
|
||||
|
||||
readonly add = output<void>();
|
||||
readonly edit = output<ArrInstance>();
|
||||
readonly delete = output<ArrInstance>();
|
||||
|
||||
readonly meta = computed(() => ARR_APP_META[this.type()]);
|
||||
readonly instances = computed(() => this.config().instances ?? []);
|
||||
|
||||
readonly strikesOpen = signal(false);
|
||||
|
||||
readonly failedImportSaving = signal(false);
|
||||
readonly failedImportSaved = signal(false);
|
||||
readonly failedImportModel = signal<FailedImportFormModel>({ override: false, maxStrikes: 3 });
|
||||
readonly failedImportForm = form(this.failedImportModel, (p) => {
|
||||
validate(p.maxStrikes, ({ value, valueOf }) => {
|
||||
if (!valueOf(p.override)) {
|
||||
return undefined;
|
||||
}
|
||||
if (value() === null) {
|
||||
return { kind: 'required', message: 'This field is required' };
|
||||
}
|
||||
if (!Number.isInteger(value())) {
|
||||
return { kind: 'integer', message: 'Value must be a whole number' };
|
||||
}
|
||||
if (value()! < 0) {
|
||||
return { kind: 'min', message: 'Value cannot be negative' };
|
||||
}
|
||||
if (value()! > 0 && value()! < 3) {
|
||||
return { kind: 'min', message: 'Minimum value is 3' };
|
||||
}
|
||||
if (value()! > 5000) {
|
||||
return { kind: 'max', message: 'Value cannot exceed 5000' };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
readonly failedImportHasErrors = computed(() => this.failedImportForm().invalid());
|
||||
|
||||
private readonly failedImportDirtyTracker = createDirtyTracker(
|
||||
this.failedImportModel,
|
||||
(m) => (m.override ? m.maxStrikes : -1),
|
||||
);
|
||||
readonly failedImportDirty = this.failedImportDirtyTracker.dirty;
|
||||
private seededType: ArrType | null = null;
|
||||
|
||||
readonly panelVisible = computed(() =>
|
||||
this.strikesOpen() || this.failedImportDirty() || this.failedImportHasErrors(),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const type = this.type();
|
||||
const config = this.config();
|
||||
untracked(() => {
|
||||
if (this.seededType === type && this.failedImportDirty()) {
|
||||
return;
|
||||
}
|
||||
this.seededType = type;
|
||||
this.failedImportModel.set(
|
||||
config.failedImportMaxStrikes >= 0
|
||||
? { override: true, maxStrikes: config.failedImportMaxStrikes }
|
||||
: { override: false, maxStrikes: 3 },
|
||||
);
|
||||
this.failedImportDirtyTracker.markSaved();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
themedIconSrc(src: string): string {
|
||||
return this.themeService.themedIconSrc(src);
|
||||
}
|
||||
|
||||
toggleStrikesPanel(): void {
|
||||
this.strikesOpen.update((v) => !v);
|
||||
}
|
||||
|
||||
saveFailedImport(): void {
|
||||
if (this.failedImportForm().invalid()) {
|
||||
return;
|
||||
}
|
||||
const m = this.failedImportModel();
|
||||
const maxStrikes = m.override ? (m.maxStrikes ?? 3) : -1;
|
||||
|
||||
saveSettings({
|
||||
request: this.api.updateConfig(this.type(), { failedImportMaxStrikes: maxStrikes }),
|
||||
toast: this.toast,
|
||||
saving: this.failedImportSaving,
|
||||
saved: this.failedImportSaved,
|
||||
successMessage: 'Failed import settings saved',
|
||||
onSaved: () => this.failedImportDirtyTracker.markSaved(m),
|
||||
});
|
||||
}
|
||||
|
||||
onAddClick(): void {
|
||||
this.add.emit();
|
||||
}
|
||||
|
||||
onEditClick(instance: ArrInstance): void {
|
||||
this.edit.emit(instance);
|
||||
}
|
||||
|
||||
onDeleteClick(instance: ArrInstance): void {
|
||||
this.delete.emit(instance);
|
||||
}
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return this.failedImportDirty();
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<app-page-header
|
||||
[title]="displayName() + ' Settings'"
|
||||
[subtitle]="'Manage your ' + displayName() + ' instances'"
|
||||
[badge]="badge()"
|
||||
/>
|
||||
|
||||
@if (loadError()) {
|
||||
<app-empty-state
|
||||
icon="tablerPlugConnectedX"
|
||||
heading="Could not connect to server"
|
||||
description="Failed to load settings. Please check that the server is running and try again."
|
||||
>
|
||||
<app-button variant="primary" size="sm" (clicked)="retry()">
|
||||
Retry
|
||||
</app-button>
|
||||
</app-empty-state>
|
||||
} @else if (loader.showSpinner()) {
|
||||
<app-loading-state message="Loading settings..." />
|
||||
} @else if (!loader.loading()) {
|
||||
<div class="settings-form">
|
||||
<app-card header="Failed Import">
|
||||
<div class="form-stack">
|
||||
<app-toggle label="Override global max strikes" [formField]="failedImportForm.override"
|
||||
hint="Use a different failed import max strikes value than the queue cleaner setting"
|
||||
helpKey="arr:failedImportOverride" />
|
||||
@if (failedImportForm.override().value()) {
|
||||
<app-number-input label="Max Strikes" [formField]="failedImportForm.maxStrikes"
|
||||
hint="Number of strikes before action is taken (0 to disable for this app, min 3 to enable)"
|
||||
[error]="failedImportForm.maxStrikes().errors()[0]?.message"
|
||||
helpKey="arr:failedImportMaxStrikes" />
|
||||
}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<app-button variant="primary" [glowing]="failedImportDirty()" [loading]="failedImportSaving()"
|
||||
[disabled]="failedImportSaving() || failedImportSaved() || failedImportHasErrors() || !failedImportDirty()"
|
||||
(clicked)="saveFailedImport()">
|
||||
{{ failedImportSaved() ? 'Saved!' : 'Save Settings' }}
|
||||
</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<app-card>
|
||||
<div class="instances-header">
|
||||
<h3 class="instances-header__title">Instances</h3>
|
||||
<app-button variant="primary" size="sm" (clicked)="openAddModal()">
|
||||
Add Instance
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
@for (instance of instances(); track instance.id) {
|
||||
<div class="instance-row">
|
||||
<div class="instance-row__info">
|
||||
<span class="instance-row__name">{{ instance.name }}</span>
|
||||
<app-badge [severity]="instance.enabled ? 'success' : 'default'" size="sm">
|
||||
{{ instance.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</app-badge>
|
||||
<span class="instance-row__url">{{ instance.url }}</span>
|
||||
</div>
|
||||
<div class="instance-row__actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="openEditModal(instance)">Edit</app-button>
|
||||
<app-button variant="destructive" size="sm" (clicked)="deleteInstance(instance)">Delete</app-button>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<app-empty-state
|
||||
heading="No instances configured"
|
||||
[description]="'Add a ' + displayName() + ' instance to get started.'"
|
||||
/>
|
||||
}
|
||||
</app-card>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<app-modal
|
||||
[title]="editingInstance() ? 'Edit Instance' : 'Add Instance'"
|
||||
[(visible)]="modalVisible"
|
||||
>
|
||||
<div class="modal-form">
|
||||
<app-toggle label="Enabled" [formField]="instanceForm.enabled"
|
||||
hint="Enable or disable this instance"
|
||||
helpKey="arr:enabled" />
|
||||
<app-input label="Name" placeholder="My Instance" [formField]="instanceForm.name"
|
||||
hint="A unique name to identify this instance"
|
||||
[error]="instanceForm.name().errors()[0]?.message"
|
||||
helpKey="arr:name" />
|
||||
<app-input label="URL" [placeholder]="urlPlaceholder()" type="url" [formField]="instanceForm.url"
|
||||
hint="Full URL including protocol and port"
|
||||
[error]="instanceForm.url().errors()[0]?.message"
|
||||
helpKey="arr:url" />
|
||||
<app-input label="External URL" [placeholder]="externalUrlPlaceholder()" type="url" [formField]="instanceForm.externalUrl"
|
||||
hint="Optional URL used in notifications for clickable links (e.g., when internal Docker URLs are not reachable externally)"
|
||||
helpKey="arr:externalUrl" />
|
||||
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [formField]="instanceForm.apiKey"
|
||||
hint="API key from your arr application's Settings > General"
|
||||
[error]="instanceForm.apiKey().errors()[0]?.message"
|
||||
helpKey="arr:apiKey" />
|
||||
<app-select label="API Version" [options]="versionOptions()" [formField]="instanceForm.version"
|
||||
hint="API version for this application"
|
||||
helpKey="arr:version" />
|
||||
</div>
|
||||
<div modal-footer>
|
||||
<app-button variant="secondary" size="sm" [loading]="testing()" (clicked)="testConnection()">
|
||||
Test
|
||||
</app-button>
|
||||
<app-button variant="primary" size="sm" [loading]="saving()" [disabled]="hasModalErrors()" (clicked)="saveInstance()">
|
||||
Save
|
||||
</app-button>
|
||||
</div>
|
||||
</app-modal>
|
||||
@@ -1,26 +0,0 @@
|
||||
@use 'settings-layout' as *;
|
||||
@use 'list-layout' as *;
|
||||
|
||||
:host { @include settings-page; }
|
||||
|
||||
.settings-form { @include settings-form; }
|
||||
.modal-form { @include modal-form; }
|
||||
.form-stack { @include form-stack; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.instances-header {
|
||||
@include list-header;
|
||||
}
|
||||
|
||||
.instance-row {
|
||||
@include item-row;
|
||||
|
||||
&__url {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { ArrApi } from '@core/api/arr.api';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ArrConfig, ArrInstance } from '@shared/models/arr-config.model';
|
||||
import { ArrSettingsComponent } from './arr-settings.component';
|
||||
|
||||
const INSTANCE: ArrInstance = {
|
||||
id: 'instance-1',
|
||||
enabled: true,
|
||||
name: 'Main Sonarr',
|
||||
url: 'http://localhost:8989',
|
||||
externalUrl: 'https://sonarr.example.com',
|
||||
apiKey: 'abc123',
|
||||
version: 4,
|
||||
};
|
||||
|
||||
const DISABLED_INSTANCE: ArrInstance = {
|
||||
id: 'instance-2',
|
||||
enabled: false,
|
||||
name: 'Backup Sonarr',
|
||||
url: 'http://localhost:8990',
|
||||
apiKey: 'def456',
|
||||
version: 4,
|
||||
};
|
||||
|
||||
const CONFIG: ArrConfig = {
|
||||
failedImportMaxStrikes: 3,
|
||||
instances: [INSTANCE, DISABLED_INSTANCE],
|
||||
};
|
||||
|
||||
function createApi(config: ArrConfig = CONFIG) {
|
||||
return {
|
||||
getConfig: vi.fn(() => of(config)),
|
||||
updateConfig: vi.fn(() => of(undefined)),
|
||||
createInstance: vi.fn(() => of(INSTANCE)),
|
||||
updateInstance: vi.fn(() => of(INSTANCE)),
|
||||
deleteInstance: vi.fn(() => of(undefined)),
|
||||
testInstance: vi.fn(() => of({ message: 'Connected to Sonarr v4' })),
|
||||
};
|
||||
}
|
||||
|
||||
function createToast() {
|
||||
return { success: vi.fn(), error: vi.fn() };
|
||||
}
|
||||
|
||||
interface Setup {
|
||||
fixture: ComponentFixture<ArrSettingsComponent>;
|
||||
component: ArrSettingsComponent;
|
||||
api: ReturnType<typeof createApi>;
|
||||
toast: ReturnType<typeof createToast>;
|
||||
confirm: ConfirmService;
|
||||
}
|
||||
|
||||
function text(fixture: ComponentFixture<ArrSettingsComponent>, selector: string): string[] {
|
||||
return Array.from(fixture.nativeElement.querySelectorAll(selector)).map((el) =>
|
||||
(el as HTMLElement).textContent!.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
describe('ArrSettingsComponent', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setup(type = 'sonarr', api = createApi(), toast = createToast()): Setup {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ArrApi, useValue: api },
|
||||
{ provide: ToastService, useValue: toast },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(ArrSettingsComponent);
|
||||
fixture.componentRef.setInput('type', type);
|
||||
fixture.detectChanges();
|
||||
|
||||
return {
|
||||
fixture,
|
||||
component: fixture.componentInstance,
|
||||
api,
|
||||
toast,
|
||||
confirm: TestBed.inject(ConfirmService),
|
||||
};
|
||||
}
|
||||
|
||||
it('loads the instances for the routed arr type and renders them', () => {
|
||||
const { fixture, component, api } = setup('sonarr');
|
||||
|
||||
expect(api.getConfig).toHaveBeenCalledWith('sonarr');
|
||||
expect(component.displayName()).toBe('Sonarr');
|
||||
expect(component.instances()).toEqual([INSTANCE, DISABLED_INSTANCE]);
|
||||
expect(text(fixture, '.instance-row__name')).toEqual(['Main Sonarr', 'Backup Sonarr']);
|
||||
expect(text(fixture, '.instance-row__url')).toEqual([
|
||||
'http://localhost:8989',
|
||||
'http://localhost:8990',
|
||||
]);
|
||||
expect(fixture.nativeElement.textContent).toContain('Sonarr Settings');
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('tags the LazyLibrarian page as experimental', () => {
|
||||
const { fixture } = setup('lazylibrarian');
|
||||
|
||||
const heading = fixture.nativeElement.querySelector('.page-header__heading') as HTMLElement;
|
||||
|
||||
expect(heading.textContent).toContain('LazyLibrarian Settings');
|
||||
expect(heading.querySelector('.badge')!.textContent!.trim()).toBe('Experimental');
|
||||
});
|
||||
|
||||
it('leaves the other arr pages untagged', () => {
|
||||
const { fixture } = setup('sonarr');
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.page-header__heading .badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an empty state when the arr type has no instances', () => {
|
||||
const { fixture, component } = setup('radarr', createApi({ failedImportMaxStrikes: 0, instances: [] }));
|
||||
|
||||
expect(component.instances()).toEqual([]);
|
||||
expect(fixture.nativeElement.textContent).toContain('No instances configured');
|
||||
expect(fixture.nativeElement.textContent).toContain('Add a Radarr instance to get started.');
|
||||
});
|
||||
|
||||
it('offers only the api versions supported by the arr type and defaults to the first one', () => {
|
||||
const { fixture, component } = setup('whisparr');
|
||||
|
||||
expect(component.versionOptions()).toEqual([
|
||||
{ label: 'v2', value: 2 },
|
||||
{ label: 'v3', value: 3 },
|
||||
]);
|
||||
expect(component.instanceModel().version).toBe(2);
|
||||
|
||||
component.openAddModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
const trigger = fixture.nativeElement.querySelector('.select-trigger') as HTMLButtonElement;
|
||||
expect(trigger.textContent!.trim()).toBe('v2');
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(text(fixture, '.select-option')).toEqual(['v2', 'v3']);
|
||||
});
|
||||
|
||||
it('exposes the single supported version for a single-version arr type', () => {
|
||||
const { component } = setup('sonarr');
|
||||
|
||||
expect(component.versionOptions()).toEqual([{ label: 'v4', value: 4 }]);
|
||||
expect(component.instanceModel().version).toBe(4);
|
||||
});
|
||||
|
||||
it('falls back to no version options and version 3 for an unknown arr type', () => {
|
||||
const { fixture, component } = setup('bogusarr');
|
||||
|
||||
expect(component.versionOptions()).toEqual([]);
|
||||
expect(component.instanceModel().version).toBe(3);
|
||||
|
||||
component.openAddModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.instanceModel().version).toBe(3);
|
||||
expect(fixture.nativeElement.querySelectorAll('.select-option').length).toBe(0);
|
||||
});
|
||||
|
||||
it('shows sonarr\'s own default port and example url as placeholders', () => {
|
||||
const { component } = setup('sonarr');
|
||||
expect(component.urlPlaceholder()).toBe('http://localhost:8989');
|
||||
expect(component.externalUrlPlaceholder()).toBe('https://sonarr.example.com');
|
||||
});
|
||||
|
||||
it('shows sportarr\'s own default port and example url as placeholders, not sonarr\'s', () => {
|
||||
const { component } = setup('sportarr');
|
||||
expect(component.urlPlaceholder()).toBe('http://localhost:1867');
|
||||
expect(component.externalUrlPlaceholder()).toBe('https://sportarr.example.com');
|
||||
});
|
||||
|
||||
it('falls back to the sonarr default port for an unknown arr type url placeholder', () => {
|
||||
const { component } = setup('bogusarr');
|
||||
expect(component.urlPlaceholder()).toBe('http://localhost:8989');
|
||||
});
|
||||
|
||||
it('opens a blank add modal and blocks saving until name, url and api key are filled in', () => {
|
||||
const { fixture, component, api } = setup('sonarr');
|
||||
|
||||
component.openAddModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.editingInstance()).toBeNull();
|
||||
expect(component.modalVisible()).toBe(true);
|
||||
expect(component.instanceModel()).toEqual({
|
||||
name: '',
|
||||
url: '',
|
||||
externalUrl: '',
|
||||
apiKey: '',
|
||||
version: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Add Instance');
|
||||
expect(component.hasModalErrors()).toBe(true);
|
||||
expect(component.instanceForm.name().errors()[0].message).toBe('Name is required');
|
||||
expect(component.instanceForm.url().errors()[0].message).toBe('URL is required');
|
||||
expect(component.instanceForm.apiKey().errors()[0].message).toBe('API key is required');
|
||||
|
||||
component.saveInstance();
|
||||
expect(api.createInstance).not.toHaveBeenCalled();
|
||||
expect(component.saving()).toBe(false);
|
||||
|
||||
component.instanceForm.name().value.set('New');
|
||||
component.instanceForm.url().value.set('http://localhost:7878');
|
||||
component.instanceForm.apiKey().value.set('key');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasModalErrors()).toBe(false);
|
||||
});
|
||||
|
||||
it('creates an instance, omits a blank external url and reloads the list', () => {
|
||||
const { fixture, component, api, toast } = setup('sonarr');
|
||||
|
||||
component.openAddModal();
|
||||
component.instanceForm.name().value.set('New Sonarr');
|
||||
component.instanceForm.url().value.set('http://localhost:8989');
|
||||
component.instanceForm.apiKey().value.set('key-1');
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.createInstance).toHaveBeenCalledWith('sonarr', {
|
||||
name: 'New Sonarr',
|
||||
url: 'http://localhost:8989',
|
||||
externalUrl: undefined,
|
||||
apiKey: 'key-1',
|
||||
version: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Instance added');
|
||||
expect(component.modalVisible()).toBe(false);
|
||||
expect(component.saving()).toBe(false);
|
||||
expect(api.getConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('loads an existing instance into the modal and updates it by id', () => {
|
||||
const { fixture, component, api, toast } = setup('sonarr');
|
||||
|
||||
component.openEditModal(INSTANCE);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.editingInstance()).toBe(INSTANCE);
|
||||
expect(component.instanceModel()).toEqual({
|
||||
name: 'Main Sonarr',
|
||||
url: 'http://localhost:8989',
|
||||
externalUrl: 'https://sonarr.example.com',
|
||||
apiKey: 'abc123',
|
||||
version: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Edit Instance');
|
||||
|
||||
component.instanceForm.name().value.set('Renamed');
|
||||
fixture.detectChanges();
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateInstance).toHaveBeenCalledWith('sonarr', 'instance-1', {
|
||||
name: 'Renamed',
|
||||
url: 'http://localhost:8989',
|
||||
externalUrl: 'https://sonarr.example.com',
|
||||
apiKey: 'abc123',
|
||||
version: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(api.createInstance).not.toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith('Instance updated');
|
||||
});
|
||||
|
||||
it('keeps the modal open and reports the failure when saving fails', () => {
|
||||
const api = createApi();
|
||||
api.createInstance.mockReturnValue(throwError(() => ({ message: 'Name already exists' })));
|
||||
const { fixture, component, toast } = setup('sonarr', api);
|
||||
|
||||
component.openAddModal();
|
||||
component.instanceForm.name().value.set('New');
|
||||
component.instanceForm.url().value.set('http://localhost:8989');
|
||||
component.instanceForm.apiKey().value.set('key');
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Name already exists');
|
||||
expect(component.modalVisible()).toBe(true);
|
||||
expect(component.saving()).toBe(false);
|
||||
});
|
||||
|
||||
it('tests the connection for the edited instance and surfaces the server message', () => {
|
||||
const { fixture, component, api, toast } = setup('sonarr');
|
||||
|
||||
component.openEditModal(INSTANCE);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.testConnection();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.testInstance).toHaveBeenCalledWith('sonarr', {
|
||||
url: 'http://localhost:8989',
|
||||
apiKey: 'abc123',
|
||||
version: 4,
|
||||
instanceId: 'instance-1',
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Connected to Sonarr v4');
|
||||
expect(component.testing()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a failed connection test and stops the testing spinner', () => {
|
||||
const api = createApi();
|
||||
api.testInstance.mockReturnValue(throwError(() => ({ message: 'Connection refused' })));
|
||||
const { fixture, component, toast } = setup('sonarr', api);
|
||||
|
||||
component.openAddModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.testConnection();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.testInstance).toHaveBeenCalledWith('sonarr', {
|
||||
url: '',
|
||||
apiKey: '',
|
||||
version: 4,
|
||||
instanceId: undefined,
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('Connection refused');
|
||||
expect(component.testing()).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes an instance only after the destructive confirmation is accepted', async () => {
|
||||
const { fixture, component, api, toast, confirm } = setup('sonarr');
|
||||
|
||||
const cancelled = component.deleteInstance(INSTANCE);
|
||||
expect(confirm.state()).toMatchObject({
|
||||
title: 'Delete Instance',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
});
|
||||
expect(confirm.state()!.message).toContain('Main Sonarr');
|
||||
confirm.cancel();
|
||||
await cancelled;
|
||||
|
||||
expect(api.deleteInstance).not.toHaveBeenCalled();
|
||||
|
||||
const accepted = component.deleteInstance(INSTANCE);
|
||||
confirm.accept();
|
||||
await accepted;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.deleteInstance).toHaveBeenCalledWith('sonarr', 'instance-1');
|
||||
expect(toast.success).toHaveBeenCalledWith('Instance deleted');
|
||||
expect(api.getConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports pending changes only while the modal is open and edited', () => {
|
||||
const { fixture, component } = setup('sonarr');
|
||||
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
|
||||
component.openAddModal();
|
||||
fixture.detectChanges();
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
|
||||
component.instanceForm.name().value.set('New');
|
||||
fixture.detectChanges();
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
|
||||
component.instanceForm.url().value.set('http://localhost:8989');
|
||||
component.instanceForm.apiKey().value.set('key');
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.modalVisible()).toBe(false);
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the connection error state when loading fails and recovers on retry', () => {
|
||||
const api = createApi();
|
||||
api.getConfig.mockReturnValue(throwError(() => new Error('offline')));
|
||||
const { fixture, component, toast } = setup('sonarr', api);
|
||||
|
||||
expect(component.loadError()).toBe(true);
|
||||
expect(component.instances()).toEqual([]);
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to load Sonarr settings: offline');
|
||||
expect(fixture.nativeElement.textContent).toContain('Could not connect to server');
|
||||
|
||||
api.getConfig.mockReturnValue(of(CONFIG));
|
||||
component.retry();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.loadError()).toBe(false);
|
||||
expect(text(fixture, '.instance-row__name')).toEqual(['Main Sonarr', 'Backup Sonarr']);
|
||||
});
|
||||
|
||||
it('reports the ApiError message when testing connection fails', () => {
|
||||
const api = createApi();
|
||||
api.testInstance.mockReturnValue(throwError(() => new ApiError('Network timeout')));
|
||||
const { fixture, component, toast } = setup('sonarr', api);
|
||||
|
||||
component.openAddModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.testConnection();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Network timeout');
|
||||
expect(component.testing()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the ApiError message when saving an instance fails', () => {
|
||||
const api = createApi();
|
||||
api.updateInstance.mockReturnValue(throwError(() => new ApiError('Save error')));
|
||||
const { fixture, component, toast } = setup('sonarr', api);
|
||||
|
||||
component.openEditModal(INSTANCE);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Save error');
|
||||
expect(component.saving()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the ApiError message when deleting an instance fails', async () => {
|
||||
const api = createApi();
|
||||
api.deleteInstance.mockReturnValue(throwError(() => new ApiError('Delete error')));
|
||||
const { fixture, component, toast, confirm } = setup('sonarr', api);
|
||||
|
||||
const accepted = component.deleteInstance(INSTANCE);
|
||||
confirm.accept();
|
||||
await accepted;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Delete error');
|
||||
});
|
||||
|
||||
it('seeds the override toggle off and hides the number input when the config value is -1', () => {
|
||||
const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
expect(component.failedImportModel()).toEqual({ override: false, maxStrikes: 3 });
|
||||
expect(fixture.nativeElement.querySelector('app-number-input')).toBeNull();
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('seeds the override toggle on with the configured value', () => {
|
||||
const { component } = setup('sonarr', createApi({ failedImportMaxStrikes: 3, instances: [] }));
|
||||
|
||||
expect(component.failedImportModel()).toEqual({ override: true, maxStrikes: 3 });
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('enables the override, sets a value and saves it', () => {
|
||||
const { fixture, component, api, toast } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
component.failedImportForm.maxStrikes().value.set(5);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
|
||||
component.saveFailedImport();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: 5 });
|
||||
expect(toast.success).toHaveBeenCalledWith('Failed import settings saved');
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('disabling the override sends -1', () => {
|
||||
const { fixture, component, api } = setup('sonarr', createApi({ failedImportMaxStrikes: 5, instances: [] }));
|
||||
|
||||
component.failedImportForm.override().value.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveFailedImport();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: -1 });
|
||||
});
|
||||
|
||||
it('rejects 1 and 2 strikes, accepts 0, and disables save while invalid', () => {
|
||||
const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(1);
|
||||
fixture.detectChanges();
|
||||
expect(component.failedImportHasErrors()).toBe(true);
|
||||
expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe('Minimum value is 3');
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(2);
|
||||
fixture.detectChanges();
|
||||
expect(component.failedImportHasErrors()).toBe(true);
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(0);
|
||||
fixture.detectChanges();
|
||||
expect(component.failedImportHasErrors()).toBe(false);
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(1);
|
||||
fixture.detectChanges();
|
||||
const saveButton = fixture.nativeElement.querySelector('.form-actions button') as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects fractional failed import strikes', () => {
|
||||
const { fixture, component, api } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(3.5);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe('Value must be a whole number');
|
||||
expect(component.failedImportHasErrors()).toBe(true);
|
||||
|
||||
component.saveFailedImport();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, 'This field is required'],
|
||||
[-5, 'Value cannot be negative'],
|
||||
[5001, 'Value cannot exceed 5000'],
|
||||
])('rejects a failed import maxStrikes of %s with "%s"', (maxStrikes, message) => {
|
||||
const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(maxStrikes);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe(message);
|
||||
});
|
||||
|
||||
it('accepts a failed import maxStrikes of 5000', () => {
|
||||
const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(5000);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportForm.maxStrikes().errors().length).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores an invalid failed import maxStrikes while the override is off', () => {
|
||||
const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportForm.maxStrikes().errors().length).toBe(0);
|
||||
expect(component.failedImportHasErrors()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the failed import maxStrikes error message in the number input', () => {
|
||||
const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
component.failedImportForm.maxStrikes().value.set(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(text(fixture, '.number-error')).toEqual(['Minimum value is 3']);
|
||||
});
|
||||
|
||||
it('stays clean when the override is toggled back off after editing the hidden value', () => {
|
||||
const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] }));
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
component.failedImportForm.maxStrikes().value.set(9999);
|
||||
fixture.detectChanges();
|
||||
component.failedImportForm.override().value.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
expect(component.failedImportHasErrors()).toBe(false);
|
||||
});
|
||||
|
||||
it('toasts the error when saving the failed import override fails', () => {
|
||||
const api = createApi({ failedImportMaxStrikes: -1, instances: [] });
|
||||
api.updateConfig.mockReturnValue(throwError(() => new ApiError('Save failed')));
|
||||
const { fixture, component, toast } = setup('sonarr', api);
|
||||
|
||||
component.failedImportForm.override().value.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveFailedImport();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Save failed');
|
||||
expect(component.failedImportSaving()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps unsaved failed import edits when an instance is saved', () => {
|
||||
const { fixture, component, api } = setup('sonarr');
|
||||
|
||||
api.getConfig.mockReturnValue(of({ ...CONFIG }));
|
||||
component.failedImportForm.maxStrikes().value.set(9);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openEditModal(INSTANCE);
|
||||
fixture.detectChanges();
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.getConfig).toHaveBeenCalledTimes(2);
|
||||
expect(component.failedImportModel().maxStrikes).toBe(9);
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps unsaved failed import edits when an instance is deleted', async () => {
|
||||
const { fixture, component, api, confirm } = setup('sonarr');
|
||||
|
||||
api.getConfig.mockReturnValue(of({ ...CONFIG }));
|
||||
component.failedImportForm.maxStrikes().value.set(9);
|
||||
fixture.detectChanges();
|
||||
|
||||
const accepted = component.deleteInstance(INSTANCE);
|
||||
confirm.accept();
|
||||
await accepted;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.getConfig).toHaveBeenCalledTimes(2);
|
||||
expect(component.failedImportModel().maxStrikes).toBe(9);
|
||||
expect(component.hasPendingChanges()).toBe(true);
|
||||
});
|
||||
|
||||
it('reseeds the failed import settings on reload when there are no unsaved edits', () => {
|
||||
const api = createApi();
|
||||
const { fixture, component } = setup('sonarr', api);
|
||||
|
||||
api.getConfig.mockReturnValueOnce(of({ ...CONFIG, failedImportMaxStrikes: 7 }));
|
||||
|
||||
component.openEditModal(INSTANCE);
|
||||
fixture.detectChanges();
|
||||
component.saveInstance();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.failedImportModel().maxStrikes).toBe(7);
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('reseeds the failed import settings when the arr type changes despite unsaved edits', () => {
|
||||
const { fixture, component, api } = setup('sonarr');
|
||||
|
||||
component.failedImportForm.maxStrikes().value.set(9);
|
||||
fixture.detectChanges();
|
||||
|
||||
api.getConfig.mockReturnValue(of({ ...CONFIG, failedImportMaxStrikes: -1 }));
|
||||
fixture.componentRef.setInput('type', 'radarr');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.getConfig).toHaveBeenLastCalledWith('radarr');
|
||||
expect(component.failedImportModel()).toEqual({ override: false, maxStrikes: 3 });
|
||||
expect(component.hasPendingChanges()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,310 +0,0 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, input, computed, effect, untracked } from '@angular/core';
|
||||
import { form, required, validate, FormField } from '@angular/forms/signals';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, ToggleComponent, NumberInputComponent,
|
||||
SelectComponent, ModalComponent, EmptyStateComponent, BadgeComponent, LoadingStateComponent,
|
||||
type SelectOption,
|
||||
} from '@ui';
|
||||
import { ArrApi } from '@core/api/arr.api';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '@shared/models/arr-config.model';
|
||||
import { ArrType } from '@shared/models/enums';
|
||||
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
|
||||
import { createSettingsResource, saveSettings } from '@shared/utils/settings-resource.util';
|
||||
import { createDirtyTracker } from '@shared/utils/dirty-tracker.util';
|
||||
|
||||
const ARR_VERSION_OPTIONS: Record<string, SelectOption[]> = {
|
||||
sonarr: [{ label: 'v4', value: 4 }],
|
||||
radarr: [{ label: 'v6', value: 6 }],
|
||||
lidarr: [{ label: 'v3', value: 3 }],
|
||||
readarr: [{ label: 'v0.4', value: 0.4 }],
|
||||
whisparr: [{ label: 'v2', value: 2 }, { label: 'v3', value: 3 }],
|
||||
sportarr: [{ label: 'v3', value: 3 }],
|
||||
lazylibrarian: [{ label: 'latest', value: 1 }],
|
||||
};
|
||||
|
||||
const EXPERIMENTAL_TYPES = new Set(['lazylibrarian']);
|
||||
|
||||
const ARR_DEFAULT_PORT: Record<string, number> = {
|
||||
sonarr: 8989,
|
||||
radarr: 7878,
|
||||
lidarr: 8686,
|
||||
readarr: 8787,
|
||||
whisparr: 6969,
|
||||
sportarr: 1867,
|
||||
lazylibrarian: 5299,
|
||||
};
|
||||
|
||||
interface ArrInstanceFormModel {
|
||||
name: string;
|
||||
url: string;
|
||||
externalUrl: string;
|
||||
apiKey: string;
|
||||
version: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface FailedImportFormModel {
|
||||
override: boolean;
|
||||
maxStrikes: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-arr-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
|
||||
ToggleComponent, NumberInputComponent, SelectComponent, ModalComponent, EmptyStateComponent,
|
||||
BadgeComponent, LoadingStateComponent, FormField,
|
||||
],
|
||||
templateUrl: './arr-settings.component.html',
|
||||
styleUrl: './arr-settings.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ArrSettingsComponent implements HasPendingChanges {
|
||||
private readonly api = inject(ArrApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
|
||||
readonly type = input.required<string>();
|
||||
readonly displayName = computed(() => {
|
||||
const t = this.type();
|
||||
if (t === 'lazylibrarian') {
|
||||
return 'LazyLibrarian';
|
||||
}
|
||||
return t.charAt(0).toUpperCase() + t.slice(1);
|
||||
});
|
||||
readonly badge = computed(() => (EXPERIMENTAL_TYPES.has(this.type()) ? 'Experimental' : ''));
|
||||
readonly versionOptions = computed(() => ARR_VERSION_OPTIONS[this.type()] ?? []);
|
||||
readonly urlPlaceholder = computed(() => `http://localhost:${ARR_DEFAULT_PORT[this.type()] ?? 8989}`);
|
||||
readonly externalUrlPlaceholder = computed(() => `https://${this.type()}.example.com`);
|
||||
|
||||
private readonly settings = createSettingsResource({
|
||||
params: () => this.type(),
|
||||
load: (type) => this.api.getConfig(type as ArrType),
|
||||
errorMessage: () => `Failed to load ${this.displayName()} settings`,
|
||||
});
|
||||
private readonly configResource = this.settings.resource;
|
||||
|
||||
readonly loader = this.settings.loader;
|
||||
readonly loadError = this.settings.loadError;
|
||||
readonly saving = signal(false);
|
||||
readonly instances = computed(() =>
|
||||
this.configResource.hasValue() ? (this.configResource.value().instances ?? []) : [],
|
||||
);
|
||||
|
||||
// Failed import override
|
||||
readonly failedImportSaving = signal(false);
|
||||
readonly failedImportSaved = signal(false);
|
||||
readonly failedImportModel = signal<FailedImportFormModel>({ override: false, maxStrikes: 3 });
|
||||
readonly failedImportForm = form(this.failedImportModel, (p) => {
|
||||
validate(p.maxStrikes, ({ value, valueOf }) => {
|
||||
if (!valueOf(p.override)) {
|
||||
return undefined;
|
||||
}
|
||||
if (value() === null) {
|
||||
return { kind: 'required', message: 'This field is required' };
|
||||
}
|
||||
if (!Number.isInteger(value())) {
|
||||
return { kind: 'integer', message: 'Value must be a whole number' };
|
||||
}
|
||||
if (value()! < 0) {
|
||||
return { kind: 'min', message: 'Value cannot be negative' };
|
||||
}
|
||||
if (value()! > 0 && value()! < 3) {
|
||||
return { kind: 'min', message: 'Minimum value is 3' };
|
||||
}
|
||||
if (value()! > 5000) {
|
||||
return { kind: 'max', message: 'Value cannot exceed 5000' };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
readonly failedImportHasErrors = computed(() => this.failedImportForm().invalid());
|
||||
|
||||
private readonly failedImportDirtyTracker = createDirtyTracker(
|
||||
this.failedImportModel,
|
||||
(m) => (m.override ? m.maxStrikes : -1),
|
||||
);
|
||||
readonly failedImportDirty = this.failedImportDirtyTracker.dirty;
|
||||
/** Arr type the failed import model was last seeded from; unsaved edits survive only same-type reloads. */
|
||||
private seededType: string | null = null;
|
||||
|
||||
// Modal state
|
||||
readonly modalVisible = signal(false);
|
||||
readonly editingInstance = signal<ArrInstance | null>(null);
|
||||
readonly testing = signal(false);
|
||||
|
||||
readonly instanceModel = signal<ArrInstanceFormModel>({
|
||||
name: '', url: '', externalUrl: '', apiKey: '', version: 3, enabled: true,
|
||||
});
|
||||
readonly instanceForm = form(this.instanceModel, (p) => {
|
||||
required(p.name, { message: 'Name is required' });
|
||||
required(p.url, { message: 'URL is required' });
|
||||
required(p.apiKey, { message: 'API key is required' });
|
||||
});
|
||||
|
||||
readonly hasModalErrors = computed(() => this.instanceForm().invalid());
|
||||
|
||||
/** JSON snapshot of the model as loaded when the modal opened, for dirty tracking. */
|
||||
private readonly openSnapshot = signal('');
|
||||
private readonly modalDirty = computed(() =>
|
||||
this.modalVisible() && JSON.stringify(this.instanceModel()) !== this.openSnapshot());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const options = this.versionOptions();
|
||||
if (options.length > 0) {
|
||||
untracked(() => this.instanceModel.update(m => ({ ...m, version: options[0].value as number })));
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.configResource.hasValue()) {
|
||||
return;
|
||||
}
|
||||
const config = this.configResource.value();
|
||||
untracked(() => {
|
||||
const type = this.type();
|
||||
if (this.seededType === type && this.failedImportDirty()) {
|
||||
return;
|
||||
}
|
||||
this.seededType = type;
|
||||
this.failedImportModel.set(
|
||||
config.failedImportMaxStrikes >= 0
|
||||
? { override: true, maxStrikes: config.failedImportMaxStrikes }
|
||||
: { override: false, maxStrikes: 3 },
|
||||
);
|
||||
this.failedImportDirtyTracker.markSaved();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.settings.retry();
|
||||
}
|
||||
|
||||
openAddModal(): void {
|
||||
this.editingInstance.set(null);
|
||||
const options = this.versionOptions();
|
||||
this.instanceModel.set({
|
||||
name: '', url: '', externalUrl: '', apiKey: '',
|
||||
version: options.length > 0 ? (options[0].value as number) : 3,
|
||||
enabled: true,
|
||||
});
|
||||
this.openSnapshot.set(JSON.stringify(this.instanceModel()));
|
||||
this.modalVisible.set(true);
|
||||
}
|
||||
|
||||
openEditModal(instance: ArrInstance): void {
|
||||
this.editingInstance.set(instance);
|
||||
this.instanceModel.set({
|
||||
name: instance.name,
|
||||
url: instance.url,
|
||||
externalUrl: instance.externalUrl ?? '',
|
||||
apiKey: instance.apiKey,
|
||||
version: instance.version,
|
||||
enabled: instance.enabled,
|
||||
});
|
||||
this.openSnapshot.set(JSON.stringify(this.instanceModel()));
|
||||
this.modalVisible.set(true);
|
||||
}
|
||||
|
||||
testConnection(): void {
|
||||
const m = this.instanceModel();
|
||||
const request: TestArrInstanceRequest = {
|
||||
url: m.url,
|
||||
apiKey: m.apiKey,
|
||||
version: m.version ?? 3,
|
||||
instanceId: this.editingInstance()?.id,
|
||||
};
|
||||
this.testing.set(true);
|
||||
this.api.testInstance(this.type() as ArrType, request).subscribe({
|
||||
next: (result) => {
|
||||
this.toast.success(result.message || 'Connection successful');
|
||||
this.testing.set(false);
|
||||
},
|
||||
error: (err: ApiError) => {
|
||||
this.toast.error(err.message);
|
||||
this.testing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
saveInstance(): void {
|
||||
if (this.instanceForm().invalid()) {
|
||||
return;
|
||||
}
|
||||
const m = this.instanceModel();
|
||||
const dto: CreateArrInstanceDto = {
|
||||
name: m.name,
|
||||
url: m.url,
|
||||
externalUrl: m.externalUrl || undefined,
|
||||
apiKey: m.apiKey,
|
||||
version: m.version ?? 3,
|
||||
enabled: m.enabled,
|
||||
};
|
||||
|
||||
this.saving.set(true);
|
||||
const editing = this.editingInstance();
|
||||
const obs = editing?.id
|
||||
? this.api.updateInstance(this.type() as ArrType, editing.id, dto)
|
||||
: this.api.createInstance(this.type() as ArrType, dto);
|
||||
|
||||
obs.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(editing ? 'Instance updated' : 'Instance added');
|
||||
this.modalVisible.set(false);
|
||||
this.saving.set(false);
|
||||
this.configResource.reload();
|
||||
},
|
||||
error: (err: ApiError) => {
|
||||
this.toast.error(err.message);
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstance(instance: ArrInstance): Promise<void> {
|
||||
if (!instance.id) return;
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Delete Instance',
|
||||
message: `Are you sure you want to delete "${instance.name}"? This action cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.api.deleteInstance(this.type() as ArrType, instance.id).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Instance deleted');
|
||||
this.configResource.reload();
|
||||
},
|
||||
error: (err: ApiError) => this.toast.error(err.message),
|
||||
});
|
||||
}
|
||||
|
||||
saveFailedImport(): void {
|
||||
if (this.failedImportForm().invalid()) {
|
||||
return;
|
||||
}
|
||||
const m = this.failedImportModel();
|
||||
const maxStrikes = m.override ? (m.maxStrikes ?? 3) : -1;
|
||||
|
||||
saveSettings({
|
||||
request: this.api.updateConfig(this.type() as ArrType, { failedImportMaxStrikes: maxStrikes }),
|
||||
toast: this.toast,
|
||||
saving: this.failedImportSaving,
|
||||
saved: this.failedImportSaved,
|
||||
successMessage: 'Failed import settings saved',
|
||||
onSaved: () => this.failedImportDirtyTracker.markSaved(m),
|
||||
});
|
||||
}
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return this.modalDirty() || this.failedImportDirty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { legacyArrRedirect } from './legacy-arr-redirect';
|
||||
|
||||
describe('legacyArrRedirect', () => {
|
||||
it('redirects /settings/arr/:type to /settings/arr with fragment', () => {
|
||||
TestBed.runInInjectionContext(() => {
|
||||
const result = legacyArrRedirect({ params: { type: 'radarr' } } as unknown as Parameters<typeof legacyArrRedirect>[0]);
|
||||
|
||||
expect(result).toEqual(
|
||||
TestBed.inject(Router).createUrlTree(['/settings/arr'], { fragment: 'radarr' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles sonarr type', () => {
|
||||
TestBed.runInInjectionContext(() => {
|
||||
const result = legacyArrRedirect({ params: { type: 'sonarr' } } as unknown as Parameters<typeof legacyArrRedirect>[0]);
|
||||
const expected = TestBed.inject(Router).createUrlTree(['/settings/arr'], { fragment: 'sonarr' });
|
||||
|
||||
expect(result.toString()).toEqual(expected.toString());
|
||||
});
|
||||
});
|
||||
|
||||
it('handles lazylibrarian type', () => {
|
||||
TestBed.runInInjectionContext(() => {
|
||||
const result = legacyArrRedirect({ params: { type: 'lazylibrarian' } } as unknown as Parameters<typeof legacyArrRedirect>[0]);
|
||||
const expected = TestBed.inject(Router).createUrlTree(['/settings/arr'], { fragment: 'lazylibrarian' });
|
||||
|
||||
expect(result.toString()).toEqual(expected.toString());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Router, RedirectFunction } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
export const legacyArrRedirect: RedirectFunction = ({ params }) => {
|
||||
return inject(Router).createUrlTree(['/settings/arr'], { fragment: params['type'] });
|
||||
};
|
||||
@@ -69,71 +69,6 @@
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.provider-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.provider-grid { @include selection-grid; }
|
||||
|
||||
.provider-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--glass-bg);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--glass-bg-hover);
|
||||
border-color: var(--glass-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&__icon-wrapper {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-default);
|
||||
|
||||
&--light {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--normal {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__icon--light {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover &__icon--normal {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
.provider-card { @include selection-card; }
|
||||
@@ -49,35 +49,6 @@
|
||||
</a>
|
||||
}
|
||||
|
||||
<!-- Media Apps sub-group -->
|
||||
@if (!collapsed()) {
|
||||
<button class="sidebar__section-toggle sidebar__section-toggle--sub" (click)="toggleMediaApps()">
|
||||
<span class="sidebar__section-label">Media Apps</span>
|
||||
<ng-icon
|
||||
[name]="mediaAppsExpanded() ? 'tablerChevronDown' : 'tablerChevronRight'"
|
||||
class="sidebar__section-chevron"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (mediaAppsExpanded() || collapsed()) {
|
||||
@for (item of mediaAppItems; track item.route) {
|
||||
<a
|
||||
class="sidebar__item sidebar__item--deep"
|
||||
[routerLink]="item.route"
|
||||
routerLinkActive="sidebar__item--active"
|
||||
(click)="onNavItemClick()"
|
||||
>
|
||||
@if (item.iconSrc) {
|
||||
<img [src]="themeService.themedIconSrc(item.iconSrc)" alt="" aria-hidden="true" class="sidebar__item-img" />
|
||||
} @else if (item.icon) {
|
||||
<ng-icon [name]="item.icon" class="sidebar__item-icon" />
|
||||
}
|
||||
<span class="sidebar__item-label">{{ item.label }}</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
@for (item of otherSettingsItems; track item.route) {
|
||||
<a
|
||||
class="sidebar__item sidebar__item--nested"
|
||||
|
||||
@@ -122,10 +122,6 @@
|
||||
&--nested {
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
&--deep {
|
||||
padding-left: var(--space-10);
|
||||
}
|
||||
}
|
||||
|
||||
&__item-icon {
|
||||
@@ -133,13 +129,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__item-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
height: 1px;
|
||||
background: var(--sidebar-border);
|
||||
@@ -273,8 +262,7 @@
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
|
||||
&--nested,
|
||||
&--deep {
|
||||
&--nested {
|
||||
padding-left: var(--space-2);
|
||||
}
|
||||
}
|
||||
@@ -331,7 +319,6 @@
|
||||
padding: var(--space-2) var(--space-3);
|
||||
|
||||
&--nested { padding-left: var(--space-6); }
|
||||
&--deep { padding-left: var(--space-10); }
|
||||
}
|
||||
|
||||
.sidebar__item-label,
|
||||
|
||||
@@ -100,25 +100,19 @@ describe('NavSidebarComponent', () => {
|
||||
expect(fixture.nativeElement.querySelector('.sidebar__version')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.sidebar__update')).toBeNull();
|
||||
expect(link(fixture, '/settings/general')).not.toBeNull();
|
||||
expect(link(fixture, '/settings/arr/sonarr')).not.toBeNull();
|
||||
expect(link(fixture, '/settings/arr')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the grouped items when a section is folded away', () => {
|
||||
it('hides the settings items when the settings section is folded away', () => {
|
||||
const { fixture } = setup();
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.toggleMediaApps();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.mediaAppsExpanded()).toBe(false);
|
||||
expect(link(fixture, '/settings/arr/sonarr')).toBeNull();
|
||||
expect(link(fixture, '/settings/general')).not.toBeNull();
|
||||
|
||||
component.toggleSettings();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.settingsExpanded()).toBe(false);
|
||||
expect(link(fixture, '/settings/general')).toBeNull();
|
||||
expect(link(fixture, '/settings/arr')).toBeNull();
|
||||
expect(link(fixture, ROUTES.dashboard)).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ROUTES } from '@shared/routes';
|
||||
interface NavItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
iconSrc?: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
@@ -35,7 +34,6 @@ export class NavSidebarComponent {
|
||||
navClicked = output<void>();
|
||||
|
||||
settingsExpanded = signal(true);
|
||||
mediaAppsExpanded = signal(true);
|
||||
|
||||
readonly currentVersion = computed(() => this.hub.appStatus()?.currentVersion ?? null);
|
||||
readonly latestVersion = computed(() => this.hub.appStatus()?.latestVersion ?? null);
|
||||
@@ -60,16 +58,7 @@ export class NavSidebarComponent {
|
||||
{ label: 'Download Cleaner', icon: 'tablerTrash', route: '/settings/download-cleaner' },
|
||||
{ label: 'Blacklist Sync', icon: 'tablerBan', route: '/settings/blacklist-sync' },
|
||||
{ label: 'Seeker', icon: 'tablerSearch', route: '/settings/seeker' },
|
||||
];
|
||||
|
||||
mediaAppItems: NavItem[] = [
|
||||
{ label: 'Sonarr', iconSrc: 'icons/ext/sonarr-light.svg', route: '/settings/arr/sonarr' },
|
||||
{ label: 'Radarr', iconSrc: 'icons/ext/radarr-light.svg', route: '/settings/arr/radarr' },
|
||||
{ label: 'Lidarr', iconSrc: 'icons/ext/lidarr-light.svg', route: '/settings/arr/lidarr' },
|
||||
{ label: 'Readarr', iconSrc: 'icons/ext/readarr-light.svg', route: '/settings/arr/readarr' },
|
||||
{ label: 'Whisparr', iconSrc: 'icons/ext/whisparr-light.svg', route: '/settings/arr/whisparr' },
|
||||
{ label: 'Sportarr', iconSrc: 'icons/ext/sportarr-light.svg', route: '/settings/arr/sportarr' },
|
||||
{ label: 'LazyLibrarian', iconSrc: 'icons/ext/lazylibrarian-light.svg', route: '/settings/arr/lazylibrarian' },
|
||||
{ label: 'Media Managers', icon: 'tablerApps', route: '/settings/arr' },
|
||||
{ label: 'Download Clients', icon: 'tablerDownload', route: '/settings/download-clients' },
|
||||
];
|
||||
|
||||
@@ -89,10 +78,6 @@ export class NavSidebarComponent {
|
||||
this.settingsExpanded.set(!this.settingsExpanded());
|
||||
}
|
||||
|
||||
toggleMediaApps(): void {
|
||||
this.mediaAppsExpanded.set(!this.mediaAppsExpanded());
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.auth.logout();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
[class]="'btn btn--' + variant() + ' btn--' + size()"
|
||||
[class.btn--loading]="loading()"
|
||||
[class.btn--glowing]="glowing()"
|
||||
[attr.aria-expanded]="ariaExpanded()"
|
||||
[disabled]="disabled() || loading()"
|
||||
(click)="onClick($event)"
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ export class ButtonComponent {
|
||||
loading = input(false);
|
||||
type = input<'button' | 'submit' | 'reset'>('button');
|
||||
glowing = input(false);
|
||||
ariaExpanded = input<boolean | null>(null);
|
||||
|
||||
clicked = output<MouseEvent>();
|
||||
|
||||
|
||||
@@ -61,3 +61,72 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin selection-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@mixin selection-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--glass-bg);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--glass-bg-hover);
|
||||
border-color: var(--glass-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&__icon-wrapper {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-default);
|
||||
|
||||
&--light {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--normal {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__icon--light {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover &__icon--normal {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user