mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
feat(settings): server section for self-hosted storage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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[] {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user