feat(settings): server section for self-hosted storage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:55 +02:00
1 parent cec1ad83a2
commit 07b942a832
14 files changed
+553 -5

No files matched your search

@@ -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);
});
});
@@ -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,
@@ -0,0 +1,145 @@
<section class="settings-group" id="server">
<header class="settings-group__header">
<h2>{{ 'SETTINGS.NAV_SERVER' | translate }}</h2>
<p>{{ 'SETTINGS.SERVER_DESCRIPTION' | translate }}</p>
</header>
<div class="settings-card">
<div class="setting-item" data-setting-id="server-connection">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SERVER_CONNECTION' | translate }}</h4>
<p>
{{ 'SETTINGS.SERVER_CONNECTION_DESCRIPTION' | translate }}
</p>
<p class="server-detail" data-test-id="server-address">
{{ address }}
</p>
</div>
<div class="setting-item__control">
<span
class="server-state"
[attr.data-state]="state()"
data-test-id="server-state"
>
{{
(state() === 'online'
? 'SETTINGS.SERVER_CONNECTED'
: 'SETTINGS.SERVER_OFFLINE'
) | translate
}}
</span>
</div>
</div>
<div class="setting-item" data-setting-id="server-storage">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SERVER_STORAGE' | translate }}</h4>
<p>{{ 'SETTINGS.SERVER_STORAGE_DESCRIPTION' | translate }}</p>
@if (details(); as info) {
<p class="server-detail" data-test-id="server-summary">
{{
'SETTINGS.SERVER_STORAGE_SUMMARY'
| translate
: {
playlists: info.playlists,
size: sizeLabel(),
}
}}
</p>
<p class="server-detail server-detail--path">
{{ info.databasePath }}
</p>
}
</div>
<div class="setting-item__control inline-actions">
<button
mat-stroked-button
type="button"
class="inline-actions__button"
(click)="exportData.emit()"
data-test-id="server-export-backup"
>
{{ 'SETTINGS.SERVER_EXPORT_BACKUP' | translate }}
</button>
</div>
</div>
@if (showBrowserImport()) {
<div class="setting-item" data-setting-id="server-import-browser">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SERVER_IMPORT_BROWSER' | translate }}</h4>
<p>
{{
'SETTINGS.SERVER_IMPORT_BROWSER_DESCRIPTION'
| translate
}}
</p>
<p class="server-detail" role="status">
@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() }
}}
}
</p>
</div>
@if (browserPlaylistCount() > 0) {
<div class="setting-item__control">
<button
mat-flat-button
type="button"
[disabled]="importing()"
[attr.aria-busy]="importing()"
(click)="importBrowserPlaylists()"
data-test-id="server-import-browser"
>
@if (importing()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
}
{{ 'SERVER_STORAGE.MIGRATION_MOVE' | translate }}
</button>
</div>
}
</div>
}
<div class="setting-item" data-setting-id="server-access">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SERVER_ACCESS' | translate }}</h4>
<p>{{ 'SETTINGS.SERVER_ACCESS_DESCRIPTION' | translate }}</p>
<p class="server-detail">
{{
(usesToken()
? 'SETTINGS.SERVER_ACCESS_TOKEN'
: 'SETTINGS.SERVER_ACCESS_OPEN'
) | translate
}}
</p>
</div>
@if (usesToken()) {
<div class="setting-item__control">
<button
mat-stroked-button
type="button"
(click)="signOut()"
data-test-id="server-sign-out"
>
{{ 'SETTINGS.SERVER_SIGN_OUT' | translate }}
</button>
</div>
}
</div>
</div>
</section>
@@ -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<SettingsServerSectionComponent>;
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();
});
});
@@ -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<void>();
readonly state = this.status.state;
readonly usesToken = computed(
() => this.status.instance()?.auth === 'token'
);
readonly address = globalThis.location?.origin ?? '';
readonly details = signal<ServerStorageStatusResponse | null>(null);
readonly browserPlaylistCount = signal(0);
readonly importing = signal(false);
readonly importFailed = signal(false);
readonly importResult = signal<ServerMigrationResult | null>(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<void> {
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<void> {
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`;
}
@@ -119,6 +119,15 @@
(unlock)="parentalLock.unlock()"
/>
}
@case ('server') {
<!-- Deferred: keeps the server-storage code out of
the settings chunk in every other build. -->
@defer {
<app-settings-server-section
(exportData)="exportData()"
/>
}
}
@case ('backup') {
<!-- Backup & data: the former Reset page is this
page's last card, since it is one action. -->
@@ -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;
}
@@ -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[] {
+37
View File
@@ -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",
@@ -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',
@@ -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',
@@ -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<RuntimeStub> = {}) {
function setup(runtime: Partial<RuntimeStub> = {}, 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', () => {
@@ -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(),
};
}
@@ -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<SettingsSearchRequirement, boolean>