Collapse media managers into a single UI page (#843)

This commit is contained in:
Flaminel authored and GitHub committed 2026-10-15 19:05:41 +03:00
1 parent f1b4bb674c
commit 9f44e0e33f
26 files changed
+1871 -1259

No files matched your search

+4
View File
@@ -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,
};
+8 -3
View File
@@ -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);
}
}