From 07b942a83215e6f557c0c8469a3e52311afe846d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Oct 2026 23:18:17 +0200 Subject: [PATCH] feat(settings): server section for self-hosted storage Co-Authored-By: Claude Opus 5.5 --- .../src/app/settings/settings-options.spec.ts | 23 +++ apps/web/src/app/settings/settings-options.ts | 3 + .../settings-server-section.component.html | 145 ++++++++++++++++++ .../settings-server-section.component.spec.ts | 119 ++++++++++++++ .../settings-server-section.component.ts | 104 +++++++++++++ .../src/app/settings/settings.component.html | 9 ++ .../src/app/settings/settings.component.scss | 28 ++++ .../src/app/settings/settings.component.ts | 7 + apps/web/src/assets/i18n/en.json | 37 +++++ .../settings-search-entries.ts | 34 ++++ .../settings-search-sections.ts | 7 + .../settings-search.service.spec.ts | 31 +++- .../settings-search.service.ts | 7 +- .../settings-search/settings-search.types.ts | 4 +- 14 files changed, 553 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/settings/settings-server-section.component.html create mode 100644 apps/web/src/app/settings/settings-server-section.component.spec.ts create mode 100644 apps/web/src/app/settings/settings-server-section.component.ts diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts index 6b919a4f3..bdb8db5e7 100644 --- a/apps/web/src/app/settings/settings-options.spec.ts +++ b/apps/web/src/app/settings/settings-options.spec.ts @@ -42,10 +42,12 @@ describe('buildSettingsSectionNavItems', () => { const supportedItems = buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, + supportsServerStorage: true, }); const unsupportedItems = buildSettingsSectionNavItems({ supportsEpg: false, supportsRemoteControl: false, + supportsServerStorage: false, }); expect(supportedItems.map((item) => item.id)).toEqual( @@ -65,6 +67,7 @@ describe('buildSettingsSectionNavItems', () => { buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, + supportsServerStorage: true, }).map((item) => item.id) ); const renderableIds = collectRenderableSectionIds(); @@ -86,6 +89,7 @@ describe('buildSettingsSectionNavItems', () => { buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, + supportsServerStorage: true, }).map((item) => item.id) ).toEqual([ 'general', @@ -95,6 +99,7 @@ describe('buildSettingsSectionNavItems', () => { 'tmdb', 'parental', 'remote-control', + 'server', 'backup', 'about', ]); @@ -104,6 +109,7 @@ describe('buildSettingsSectionNavItems', () => { const items = buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, + supportsServerStorage: true, }); const groupOf = (id: string) => items.find((item) => item.id === id)?.group; @@ -111,7 +117,24 @@ describe('buildSettingsSectionNavItems', () => { expect(groupOf('general')).toBe('app'); expect(groupOf('tmdb')).toBe('library'); expect(groupOf('remote-control')).toBe('devices'); + expect(groupOf('server')).toBe('data'); expect(groupOf('backup')).toBe('data'); expect(groupOf('about')).toBeUndefined(); }); + + it('shows the server section only with server storage', () => { + const build = (supportsServerStorage: boolean) => + buildSettingsSectionNavItems({ + supportsEpg: false, + supportsRemoteControl: false, + supportsServerStorage, + }).find((item) => item.id === 'server'); + + expect(build(true)).toMatchObject({ + visible: true, + group: 'data', + icon: 'dns', + }); + expect(build(false)?.visible).toBe(false); + }); }); diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index ff0c085d2..e37c99b66 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -162,15 +162,18 @@ export function buildSettingsPlayerOptions({ export interface SettingsSectionVisibility { supportsEpg: boolean; supportsRemoteControl: boolean; + supportsServerStorage: boolean; } export function buildSettingsSectionNavItems({ supportsEpg, supportsRemoteControl, + supportsServerStorage, }: SettingsSectionVisibility): SettingsSection[] { const capabilities = { epg: supportsEpg, 'remote-control': supportsRemoteControl, + 'server-storage': supportsServerStorage, }; return SETTINGS_SECTION_DEFINITIONS.map((section) => ({ id: section.id, diff --git a/apps/web/src/app/settings/settings-server-section.component.html b/apps/web/src/app/settings/settings-server-section.component.html new file mode 100644 index 000000000..f080a71ff --- /dev/null +++ b/apps/web/src/app/settings/settings-server-section.component.html @@ -0,0 +1,145 @@ +
+
+

{{ 'SETTINGS.NAV_SERVER' | translate }}

+

{{ 'SETTINGS.SERVER_DESCRIPTION' | translate }}

+
+ +
+
+
+

{{ 'SETTINGS.SERVER_CONNECTION' | translate }}

+

+ {{ 'SETTINGS.SERVER_CONNECTION_DESCRIPTION' | translate }} +

+

+ {{ address }} +

+
+
+ + {{ + (state() === 'online' + ? 'SETTINGS.SERVER_CONNECTED' + : 'SETTINGS.SERVER_OFFLINE' + ) | translate + }} + +
+
+ +
+
+

{{ 'SETTINGS.SERVER_STORAGE' | translate }}

+

{{ 'SETTINGS.SERVER_STORAGE_DESCRIPTION' | translate }}

+ @if (details(); as info) { +

+ {{ + 'SETTINGS.SERVER_STORAGE_SUMMARY' + | translate + : { + playlists: info.playlists, + size: sizeLabel(), + } + }} +

+

+ {{ info.databasePath }} +

+ } +
+
+ +
+
+ + @if (showBrowserImport()) { +
+
+

{{ 'SETTINGS.SERVER_IMPORT_BROWSER' | translate }}

+

+ {{ + 'SETTINGS.SERVER_IMPORT_BROWSER_DESCRIPTION' + | translate + }} +

+

+ @if (importResult(); as done) { + {{ + 'SERVER_STORAGE.MIGRATION_DONE' + | translate: done + }} + } @else if (importFailed()) { + {{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }} + } @else { + {{ + 'SETTINGS.SERVER_IMPORT_BROWSER_COUNT' + | translate + : { count: browserPlaylistCount() } + }} + } +

+
+ @if (browserPlaylistCount() > 0) { +
+ +
+ } +
+ } + +
+
+

{{ 'SETTINGS.SERVER_ACCESS' | translate }}

+

{{ 'SETTINGS.SERVER_ACCESS_DESCRIPTION' | translate }}

+

+ {{ + (usesToken() + ? 'SETTINGS.SERVER_ACCESS_TOKEN' + : 'SETTINGS.SERVER_ACCESS_OPEN' + ) | translate + }} +

+
+ @if (usesToken()) { +
+ +
+ } +
+
+
diff --git a/apps/web/src/app/settings/settings-server-section.component.spec.ts b/apps/web/src/app/settings/settings-server-section.component.spec.ts new file mode 100644 index 000000000..03d38eada --- /dev/null +++ b/apps/web/src/app/settings/settings-server-section.component.spec.ts @@ -0,0 +1,119 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { + ServerMigrationService, + ServerStorageSession, +} from '@iptvnator/services/server-storage'; +import { SettingsServerSectionComponent } from './settings-server-section.component'; + +describe('SettingsServerSectionComponent', () => { + let fixture: ComponentFixture; + const session = { fetchStatus: jest.fn(), signOut: jest.fn() }; + const migration = { + browserPlaylists: jest.fn(), + browserDataMoved: jest.fn(), + moveBrowserData: jest.fn(), + }; + + async function render( + auth: 'none' | 'token', + browserPlaylists = [{ id: 'a', title: 'A', kind: 'm3u' }] + ) { + session.fetchStatus.mockResolvedValue({ + playlists: 4, + databaseBytes: 38 * 1024 * 1024, + databasePath: '/data/databases/iptvnator.db', + startedAt: '2026-10-11T08:00:00.000Z', + }); + migration.browserDataMoved.mockReturnValue(false); + migration.browserPlaylists.mockResolvedValue(browserPlaylists); + await TestBed.configureTestingModule({ + imports: [ + SettingsServerSectionComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: ServerStorageSession, useValue: session }, + { provide: ServerMigrationService, useValue: migration }, + { + provide: ServerStorageStatusService, + useValue: { + state: signal('online'), + instance: signal({ auth, instanceId: 'i-1' }), + }, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(SettingsServerSectionComponent); + await fixture.componentInstance.load(); + fixture.detectChanges(); + } + + const byTestId = (id: string): HTMLElement | null => + fixture.nativeElement.querySelector(`[data-test-id="${id}"]`); + + afterEach(() => { + TestBed.resetTestingModule(); + jest.resetAllMocks(); + }); + + it('shows the storage figures and the database path', async () => { + await render('none'); + const text = fixture.nativeElement.textContent; + + expect(text).toContain('/data/databases/iptvnator.db'); + expect(byTestId('server-state')?.dataset['state']).toBe('online'); + expect(fixture.componentInstance.sizeLabel()).toBe('38.0 MB'); + expect(byTestId('server-sign-out')).toBeNull(); + }); + + it('moves browser playlists and reports the result', async () => { + await render('none'); + migration.moveBrowserData.mockResolvedValue({ + imported: 1, + merged: 0, + failed: 0, + }); + migration.browserDataMoved.mockReturnValue(true); + + byTestId('server-import-browser')?.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(migration.moveBrowserData).toHaveBeenCalled(); + expect(byTestId('server-import-browser')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'SERVER_STORAGE.MIGRATION_DONE' + ); + }); + + it('hides the browser import when there is nothing to move', async () => { + await render('none', []); + + expect( + fixture.nativeElement.querySelector( + '[data-setting-id="server-import-browser"]' + ) + ).toBeNull(); + }); + + it('offers sign out when a token is in use', async () => { + await render('token'); + + byTestId('server-sign-out')?.click(); + + expect(session.signOut).toHaveBeenCalled(); + }); + + it('asks the page to export a backup', async () => { + await render('none'); + const exported = jest.fn(); + fixture.componentInstance.exportData.subscribe(exported); + + byTestId('server-export-backup')?.click(); + + expect(exported).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/settings/settings-server-section.component.ts b/apps/web/src/app/settings/settings-server-section.component.ts new file mode 100644 index 000000000..ef04d58bc --- /dev/null +++ b/apps/web/src/app/settings/settings-server-section.component.ts @@ -0,0 +1,104 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + output, + signal, + ViewEncapsulation, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { + ServerMigrationService, + ServerStorageSession, + type ServerMigrationResult, +} from '@iptvnator/services/server-storage'; +import type { ServerStorageStatusResponse } from '@iptvnator/shared/interfaces'; + +/** + * Settings โ†’ Server: shown only when the PWA runs against self-hosted + * server storage. Loaded with `@defer`, so other builds never fetch the + * server-storage code for the settings page. + */ +@Component({ + selector: 'app-settings-server-section', + imports: [ + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + TranslatePipe, + ], + templateUrl: './settings-server-section.component.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsServerSectionComponent { + private readonly status = inject(ServerStorageStatusService); + private readonly session = inject(ServerStorageSession); + private readonly migration = inject(ServerMigrationService); + + readonly exportData = output(); + + readonly state = this.status.state; + readonly usesToken = computed( + () => this.status.instance()?.auth === 'token' + ); + readonly address = globalThis.location?.origin ?? ''; + readonly details = signal(null); + readonly browserPlaylistCount = signal(0); + readonly importing = signal(false); + readonly importFailed = signal(false); + readonly importResult = signal(null); + readonly sizeLabel = computed(() => + formatMegabytes(this.details()?.databaseBytes ?? 0) + ); + readonly showBrowserImport = computed( + () => this.browserPlaylistCount() > 0 || this.importResult() !== null + ); + + constructor() { + void this.load(); + } + + async load(): Promise { + try { + this.details.set(await this.session.fetchStatus()); + } catch { + this.details.set(null); + } + try { + this.browserPlaylistCount.set( + this.migration.browserDataMoved() + ? 0 + : (await this.migration.browserPlaylists()).length + ); + } catch { + this.browserPlaylistCount.set(0); + } + } + + async importBrowserPlaylists(): Promise { + this.importing.set(true); + this.importFailed.set(false); + try { + this.importResult.set(await this.migration.moveBrowserData()); + await this.load(); + } catch { + this.importFailed.set(true); + } finally { + this.importing.set(false); + } + } + + signOut(): void { + this.session.signOut(); + } +} + +function formatMegabytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 5823c6e0a..925cb21ee 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -119,6 +119,15 @@ (unlock)="parentalLock.unlock()" /> } + @case ('server') { + + @defer { + + } + } @case ('backup') { diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 61e825477..573ede123 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -606,3 +606,31 @@ display: none; } } + +.server-state { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + color: var(--app-selection-color); + background: var(--app-selection-surface); + + &[data-state='offline'], + &[data-state='unauthorized'] { + color: var(--app-expiry-warn-text); + background: var(--app-expiry-warn-surface); + } +} + +.setting-item__meta .server-detail { + color: var(--app-muted-color); + overflow-wrap: anywhere; +} + +.setting-item__meta .server-detail--path { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + font-weight: 500; + font-size: 0.78rem; +} diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index db039148c..f0c153944 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -22,6 +22,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { EpgSourceReconciliationError, RuntimeCapabilitiesService, + ServerStorageStatusService, injectTranslationTick, } from '@iptvnator/services'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; @@ -61,6 +62,7 @@ import { SettingsParentalLockSectionComponent } from './settings-parental-sectio import { SettingsPlaybackSectionComponent } from './settings-playback-section.component'; import { SettingsRemoteControlFacade } from './settings-remote-control.facade'; import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component'; +import { SettingsServerSectionComponent } from './settings-server-section.component'; import { SettingsSearchFacade } from './settings-search.facade'; import { SettingsSearchResultsComponent } from './settings-search-results.component'; import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component'; @@ -108,6 +110,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsPlaybackSectionComponent, SettingsRemoteControlSectionComponent, SettingsSearchResultsComponent, + SettingsServerSectionComponent, SettingsTmdbSectionComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -166,6 +169,9 @@ export class SettingsComponent this.runtime.supportsExternalPlayerPathSettings; readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable; readonly supportsRemoteControl = this.runtime.supportsRemoteControl; + readonly supportsServerStorage = inject( + ServerStorageStatusService + ).isActive(); readonly supportsStartupWindowMode = this.runtime.supportsStartupWindowMode; readonly supportsPortalConnectivityGuard = this.runtime.supportsPortalConnectivityGuard; @@ -230,6 +236,7 @@ export class SettingsComponent readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({ supportsEpg: this.supportsEpg, supportsRemoteControl: this.supportsRemoteControl, + supportsServerStorage: this.supportsServerStorage, }); get sectionNav(): SettingsSection[] { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 084229dc6..3647f60e2 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -312,6 +312,24 @@ "NAV_REMOTE": "Remote", "NAV_DATA": "Data", "NAV_BACKUP": "Backup & data", + "NAV_SERVER": "Server", + "SERVER_DESCRIPTION": "Playlists, favorites and watch progress are stored on this server and shared by every device that opens it.", + "SERVER_CONNECTION": "Connection", + "SERVER_CONNECTION_DESCRIPTION": "The IPTVnator server this app saves to.", + "SERVER_CONNECTED": "Connected", + "SERVER_OFFLINE": "Unavailable", + "SERVER_STORAGE": "Storage", + "SERVER_STORAGE_DESCRIPTION": "Everything is kept in one database file on the server. Export a backup to keep a copy elsewhere.", + "SERVER_STORAGE_SUMMARY": "{{playlists}} playlists ยท {{size}}", + "SERVER_EXPORT_BACKUP": "Export backup", + "SERVER_IMPORT_BROWSER": "Playlists in this browser", + "SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlists saved in this browser before it used the server. Move them so every device sees them; playlists the server already has are merged.", + "SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlists are stored only in this browser.", + "SERVER_ACCESS": "Access", + "SERVER_ACCESS_DESCRIPTION": "Who can open this server's library.", + "SERVER_ACCESS_OPEN": "Anyone who can reach this address can use it. Set IPTVNATOR_ACCESS_TOKEN on the server to require a token.", + "SERVER_ACCESS_TOKEN": "This server requires an access token.", + "SERVER_SIGN_OUT": "Sign out", "NAV_ABOUT": "About", "NAV_GROUP_APP": "App", "NAV_GROUP_LIBRARY": "Library", @@ -638,6 +656,25 @@ "LOCKED_TOAST": "Locked" } }, + "SERVER_STORAGE": { + "WRITE_FAILED": "Could not save to the server. Check the connection and try again.", + "TOO_LARGE": "This playlist is larger than the server accepts.", + "MIGRATION_TITLE": "Found {{count}} playlists in this browser", + "MIGRATION_DESCRIPTION": "They are stored only in this browser. Move them to the server so every device that opens this address sees them. Nothing is deleted from this browser.", + "MIGRATION_LATER_HINT": "You can do this later in Settings โ†’ Server.", + "MIGRATION_NOT_NOW": "Not now", + "MIGRATION_MOVE": "Move to server", + "MIGRATION_DONE": "Moved {{imported}}, merged {{merged}}, failed {{failed}}.", + "MIGRATION_FAILED": "Moving failed. Your playlists are still in this browser; try again from Settings โ†’ Server.", + "MIGRATION_RELOAD": "Reload", + "SIGN_IN_TITLE": "Access token required", + "SIGN_IN_DESCRIPTION": "This IPTVnator server asks for an access token before it shows your playlists.", + "SIGN_IN_TOKEN_LABEL": "Access token", + "SIGN_IN_HINT": "Find it in the server's IPTVNATOR_ACCESS_TOKEN setting.", + "SIGN_IN_REJECTED": "Token rejected. Check it and try again.", + "SIGN_IN_UNREACHABLE": "Cannot reach the server. Check the connection and try again.", + "SIGN_IN_CONTINUE": "Continue" + }, "THEMES": { "DARK_THEME": "Dark", "LIGHT_THEME": "Light", diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts index 0776d219a..1906598c3 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts @@ -2,6 +2,7 @@ import { PLAYBACK_SETTINGS_SEARCH_ENTRIES } from './settings-search-entries-play import { SettingsSearchEntry } from './settings-search.types'; const DASHBOARD_KEYWORDS = ['dashboard', 'home', 'rail', 'row']; +const SERVER_KEYWORDS = ['server', 'self-hosted', 'nas', 'sync', 'docker']; function dashboardRail( id: string, @@ -244,6 +245,39 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [ keywords: ['parental', 'lock categories', 'groups', 'right-click'], fallbackId: 'parental-lock-enable', }, + { + id: 'server-connection', + section: 'server', + labelKey: 'SETTINGS.SERVER_CONNECTION', + descriptionKey: 'SETTINGS.SERVER_CONNECTION_DESCRIPTION', + keywords: SERVER_KEYWORDS, + requires: ['server-storage'], + }, + { + id: 'server-storage', + section: 'server', + labelKey: 'SETTINGS.SERVER_STORAGE', + descriptionKey: 'SETTINGS.SERVER_STORAGE_DESCRIPTION', + keywords: [...SERVER_KEYWORDS, 'database', 'backup', 'size'], + requires: ['server-storage'], + }, + { + id: 'server-import-browser', + section: 'server', + labelKey: 'SETTINGS.SERVER_IMPORT_BROWSER', + descriptionKey: 'SETTINGS.SERVER_IMPORT_BROWSER_DESCRIPTION', + keywords: [...SERVER_KEYWORDS, 'import', 'migrate', 'browser'], + requires: ['server-storage'], + fallbackId: 'server-storage', + }, + { + id: 'server-access', + section: 'server', + labelKey: 'SETTINGS.SERVER_ACCESS', + descriptionKey: 'SETTINGS.SERVER_ACCESS_DESCRIPTION', + keywords: [...SERVER_KEYWORDS, 'token', 'sign out', 'password'], + requires: ['server-storage'], + }, { id: 'import-export-data', section: 'backup', diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts index 0e7830871..4d3c23565 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts @@ -65,6 +65,13 @@ export const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] requires: ['remote-control'], group: 'devices', }, + { + id: 'server', + navLabelKey: 'SETTINGS.NAV_SERVER', + icon: 'dns', + requires: ['server-storage'], + group: 'data', + }, { id: 'backup', navLabelKey: 'SETTINGS.NAV_BACKUP', diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts index 260e83686..2c4c06256 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts @@ -2,7 +2,10 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + RuntimeCapabilitiesService, + ServerStorageStatusService, +} from '@iptvnator/services'; import { EMBEDDED_MPV_SUPPORT_RECHECK_MS } from '@iptvnator/shared/interfaces'; import { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries'; import { SETTINGS_SECTION_DEFINITIONS } from './settings-search-sections'; @@ -31,10 +34,14 @@ interface RuntimeStub { supportsEmbeddedMpv: boolean; } -function setup(runtime: Partial = {}) { +function setup(runtime: Partial = {}, serverStorage = false) { const router = { navigate: jest.fn().mockResolvedValue(true) }; TestBed.configureTestingModule({ providers: [ + { + provide: ServerStorageStatusService, + useValue: { isActive: () => serverStorage }, + }, { provide: RuntimeCapabilitiesService, useValue: { @@ -131,7 +138,25 @@ describe('SettingsSearchService', () => { // Embedded MPV rows wait for the support probe. expect(entries).not.toContain('embedded-mpv-extra-options'); expect(entries).not.toContain('embedded-mpv-frame-copy'); - expect(entries.length).toBe(SETTINGS_SEARCH_ENTRIES.length - 4); + // Server rows need the self-hosted PWA's server storage. + expect(entries).not.toContain('server-connection'); + expect(entries.length).toBe(SETTINGS_SEARCH_ENTRIES.length - 8); + }); + + it('offers the server section and rows with server storage', () => { + const { service } = setup({}, true); + + expect(service.visibleSections().map(({ id }) => id)).toContain( + 'server' + ); + expect(service.visibleEntries().map(({ id }) => id)).toEqual( + expect.arrayContaining([ + 'server-connection', + 'server-storage', + 'server-import-browser', + 'server-access', + ]) + ); }); describe('embedded MPV probe', () => { diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts index 1a17827c1..8ba8055a1 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts @@ -2,7 +2,10 @@ import { inject, Injectable, signal } from '@angular/core'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + RuntimeCapabilitiesService, + ServerStorageStatusService, +} from '@iptvnator/services'; import { EmbeddedMpvSupport, watchEmbeddedMpvSupport, @@ -30,6 +33,7 @@ import { @Injectable({ providedIn: 'root' }) export class SettingsSearchService { private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly serverStorageStatus = inject(ServerStorageStatusService); private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService); private readonly router = inject(Router); private readonly translate = inject(TranslateService); @@ -61,6 +65,7 @@ export class SettingsSearchService { 'embedded-mpv': !!this.embeddedMpvSupport()?.supported, 'embedded-mpv-frame-copy': !!this.embeddedMpvSupport()?.frameCopyAvailable, + 'server-storage': this.serverStorageStatus.isActive(), }; } diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts index 92cd5fbd3..0246d5731 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts @@ -14,7 +14,9 @@ export type SettingsSearchRequirement = | 'vod-multi-source' /** Probed lazily; false until `ensureEmbeddedMpvSupportLoaded` resolves. */ | 'embedded-mpv' - | 'embedded-mpv-frame-copy'; + | 'embedded-mpv-frame-copy' + /** The PWA runs against self-hosted server storage. */ + | 'server-storage'; export type SettingsSearchCapabilities = Readonly< Record