From 0245d73d78a88919972e2783b2c588f2ac8435d1 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:18:05 +0200 Subject: [PATCH] feat(portals): make connection cooldown configurable in desktop settings (#1536) --- .changes/portals-connectivity-setting.md | 6 + AGENTS.md | 14 ++ CLAUDE.md | 14 ++ .../src/settings-connectivity-guard.e2e.ts | 147 ++++++++++++++++++ .../src/app/events/settings.events.spec.ts | 33 ++++ .../src/app/events/settings.events.ts | 11 ++ .../src/app/services/store.service.ts | 4 + .../app/util/host-connectivity-guard.spec.ts | 58 +++++++ .../src/app/util/host-connectivity-guard.ts | 24 ++- .../app/settings/settings-form.utils.spec.ts | 10 ++ .../src/app/settings/settings-form.utils.ts | 2 + .../settings-general-section.component.html | 51 +++++- .../settings-general-section.component.ts | 1 + .../settings/settings.component.form.spec.ts | 34 ++++ .../src/app/settings/settings.component.html | 3 + .../src/app/settings/settings.component.ts | 2 + .../test-stubs/settings-test-harness.stub.ts | 4 + apps/web/src/assets/i18n/ar.json | 9 ++ apps/web/src/assets/i18n/ary.json | 9 ++ apps/web/src/assets/i18n/by.json | 9 ++ apps/web/src/assets/i18n/de.json | 9 ++ apps/web/src/assets/i18n/el.json | 9 ++ apps/web/src/assets/i18n/en.json | 9 ++ apps/web/src/assets/i18n/es.json | 9 ++ apps/web/src/assets/i18n/fr.json | 9 ++ apps/web/src/assets/i18n/hu.json | 9 ++ apps/web/src/assets/i18n/it.json | 9 ++ apps/web/src/assets/i18n/ja.json | 9 ++ apps/web/src/assets/i18n/ko.json | 9 ++ apps/web/src/assets/i18n/nl.json | 9 ++ apps/web/src/assets/i18n/pl.json | 9 ++ apps/web/src/assets/i18n/pt.json | 9 ++ apps/web/src/assets/i18n/ru.json | 9 ++ apps/web/src/assets/i18n/tr.json | 9 ++ apps/web/src/assets/i18n/zh.json | 9 ++ apps/web/src/assets/i18n/zhtw.json | 9 ++ docs/architecture/host-connectivity-guard.md | 29 ++++ .../stalker-account-info.component.html | 32 +++- .../stalker-account-info.component.spec.ts | 27 ++++ .../stalker-account-info.component.ts | 13 +- .../account-info/account-info.component.html | 28 +++- .../account-info.component.spec.ts | 32 ++++ .../account-info/account-info.component.ts | 13 +- .../lib/runtime-capabilities.service.spec.ts | 10 ++ .../src/lib/runtime-capabilities.service.ts | 8 + .../src/lib/settings-store.service.spec.ts | 19 +++ .../src/lib/settings-store.service.ts | 12 ++ .../interfaces/src/lib/settings.interface.ts | 2 + 48 files changed, 787 insertions(+), 27 deletions(-) create mode 100644 .changes/portals-connectivity-setting.md create mode 100644 apps/electron-backend-e2e/src/settings-connectivity-guard.e2e.ts diff --git a/.changes/portals-connectivity-setting.md b/.changes/portals-connectivity-setting.md new file mode 100644 index 000000000..65acde25c --- /dev/null +++ b/.changes/portals-connectivity-setting.md @@ -0,0 +1,6 @@ +--- +type: feature +area: portals +--- + +Desktop settings now let you disable the temporary pause after repeated Xtream or Stalker connection failures, without restarting. Account info explains when requests are paused and offers an immediate retry. diff --git a/AGENTS.md b/AGENTS.md index dc8d0321c..d1616fbb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,20 @@ agent-browser connect ws://127.0.0.1:9222/devtools/page/ agent-browser screenshot /tmp/iptvnator-cdp.png ``` +## Portal Connectivity Preference + +- Desktop Settings > General > Portal connections exposes default-on + `Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors + the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart; + settings bootstrap restores it before the renderer loads. It controls Xtream + and Stalker together. Preference transitions clear cooldowns and invalidate old + request completions; unchanged saves preserve evidence. The environment switch + `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not + control the shared backend's guard. +- Both account-info dialogs explain guard refusals with localized paused-request + copy and Retry now; Stalker preserves cached account data on a failed refresh. + Contract: `docs/architecture/host-connectivity-guard.md`. + ## Radio / Audio Player M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected: diff --git a/CLAUDE.md b/CLAUDE.md index 1ddc0c668..75b4f3520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1625,3 +1625,17 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c - The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax + +## Portal Connectivity Preference + +- Desktop Settings > General > Portal connections exposes default-on + `Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors + the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart; + settings bootstrap restores it before the renderer loads. It controls Xtream + and Stalker together. Preference transitions clear cooldowns and invalidate old + request completions; unchanged saves preserve evidence. The environment switch + `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not + control the shared backend's guard. +- Both account-info dialogs explain guard refusals with localized paused-request + copy and Retry now; Stalker preserves cached account data on a failed refresh. + Contract: `docs/architecture/host-connectivity-guard.md`. diff --git a/apps/electron-backend-e2e/src/settings-connectivity-guard.e2e.ts b/apps/electron-backend-e2e/src/settings-connectivity-guard.e2e.ts new file mode 100644 index 000000000..4082eab85 --- /dev/null +++ b/apps/electron-backend-e2e/src/settings-connectivity-guard.e2e.ts @@ -0,0 +1,147 @@ +import { Page } from '@playwright/test'; +import { createServer } from 'node:net'; +import { + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openSettingsSection, + saveSettings, + test, +} from './electron-test-fixtures'; + +const pausedMessage = 'skipped after repeated connection failures'; +// Xtream currently rejects network failures with a plain object, whose fields +// Electron discards. Stalker preserves the error code in its Error message. +const xtreamNetworkFailure = + "Error invoking remote method 'XTREAM_REQUEST': [object Object]"; + +async function unusedLocalOrigin(): Promise { + const server = createServer(); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('No test port'); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + return `http://127.0.0.1:${address.port}`; +} + +/** Real IPC/network failure: both protocols must share the saved policy. */ +async function requestFailure(page: Page, origin: string, stalker = false) { + return page.evaluate( + async ({ origin, stalker }) => { + try { + if (stalker) { + await window.electron.stalkerRequest({ + url: `${origin}/portal.php`, + macAddress: '00:1A:79:00:00:01', + params: { type: 'stb', action: 'handshake' }, + }); + } else { + await window.electron.xtreamRequest({ + url: origin, + params: { + username: 'test', + password: 'test', + action: 'get_account_info', + }, + }); + } + return 'unexpected success'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + { origin, stalker } + ); +} + +test('@settings @electron saves the portal cooldown preference and applies it without restart', async ({ + dataDir, +}) => { + const origin = await unusedLocalOrigin(); + let app = await launchElectronApp(dataDir, { + omitEnvKeys: ['IPTVNATOR_DISABLE_CONNECTIVITY_GUARD'], + }); + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'general'); + let checkbox = app.mainWindow + .getByTestId('portal-connectivity-toggle') + .getByRole('checkbox'); + await expect(checkbox).toBeChecked(); + await requestFailure(app.mainWindow, origin); + await requestFailure(app.mainWindow, origin); + expect(await requestFailure(app.mainWindow, origin, true)).toContain( + pausedMessage + ); + + await checkbox.uncheck(); + // An unsaved edit cannot alter backend policy. + expect(await requestFailure(app.mainWindow, origin)).toContain( + pausedMessage + ); + await saveSettings(app.mainWindow); + for (const stalker of [false, true, false]) { + expect( + await requestFailure(app.mainWindow, origin, stalker) + ).toContain(stalker ? 'ECONNREFUSED' : xtreamNetworkFailure); + } + await expect( + app.mainWindow.locator('mat-snack-bar-container') + ).toBeHidden(); + await app.mainWindow + .getByTestId('portal-connectivity-setting') + .screenshot({ + path: test.info().outputPath('portal-connections.png'), + }); + + await closeElectronApp(app); + app = await launchElectronApp(dataDir, { + omitEnvKeys: ['IPTVNATOR_DISABLE_CONNECTIVITY_GUARD'], + }); + // Check the cold-start mirror before opening Settings or saving again. + for (let index = 0; index < 3; index++) { + expect(await requestFailure(app.mainWindow, origin)).toContain( + xtreamNetworkFailure + ); + } + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'general'); + checkbox = app.mainWindow + .getByTestId('portal-connectivity-toggle') + .getByRole('checkbox'); + await expect(checkbox).not.toBeChecked(); + await checkbox.check(); + await saveSettings(app.mainWindow); + expect(await requestFailure(app.mainWindow, origin)).toContain( + xtreamNetworkFailure + ); + expect(await requestFailure(app.mainWindow, origin, true)).toContain( + 'ECONNREFUSED' + ); + expect(await requestFailure(app.mainWindow, origin)).toContain( + pausedMessage + ); + await app.mainWindow.getByTestId('DARK_THEME').click(); + await saveSettings(app.mainWindow); + await expect( + app.mainWindow.locator('mat-snack-bar-container') + ).toBeHidden(); + await app.mainWindow + .getByTestId('portal-connectivity-setting') + .screenshot({ + path: test.info().outputPath('portal-connections-dark.png'), + }); + // Unrelated saves (such as theme changes) must preserve the cooldown. + expect(await requestFailure(app.mainWindow, origin)).toContain( + pausedMessage + ); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index f9bae781c..65c7f459d 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -28,6 +28,7 @@ const STORE_KEYS = { MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS', MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', STARTUP_WINDOW_MODE: 'STARTUP_WINDOW_MODE', + PORTAL_CONNECTIVITY_GUARD: 'PORTAL_CONNECTIVITY_GUARD', VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', } as const; @@ -55,6 +56,7 @@ jest.mock('../services/store.service', () => ({ MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS, MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE, STARTUP_WINDOW_MODE: STORE_KEYS.STARTUP_WINDOW_MODE, + PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD, VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS, VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE, store: { @@ -101,6 +103,37 @@ describe('SETTINGS_UPDATE', () => { consoleLogSpy.mockRestore(); }); + it('restores the persisted preference before the renderer starts', async () => { + mockStoreGet.mockReturnValue(false); + const settingsEvents = (await import('./settings.events')).default; + settingsEvents.bootstrapSettingsEvents(); + const guard = await import('../util/host-connectivity-guard'); + expect( + guard.beginGuardedHostRequest('https://portal.example') + ).toBeNull(); + settingsUpdateHandler({}, { portalConnectivityGuard: true }); + expect( + guard.beginGuardedHostRequest('https://portal.example') + ).not.toBeNull(); + expect(mockStoreSet).toHaveBeenCalledWith( + STORE_KEYS.PORTAL_CONNECTIVITY_GUARD, + true + ); + }); + + it('applies an opt-out immediately and preserves it through unrelated saves', async () => { + const guard = await import('../util/host-connectivity-guard'); + settingsUpdateHandler({}, { portalConnectivityGuard: false }); + settingsUpdateHandler({}, { showCaptions: true }); + expect( + guard.beginGuardedHostRequest('https://portal.example') + ).toBeNull(); + expect(mockStoreSet).toHaveBeenCalledWith( + STORE_KEYS.PORTAL_CONNECTIVITY_GUARD, + false + ); + }); + it('normalizes external-player arguments and preserves explicit false reuse settings', () => { settingsUpdateHandler( {}, diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index 5770e4483..bb32607f5 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -12,14 +12,19 @@ import { MPV_PLAYER_ARGUMENTS, MPV_REUSE_INSTANCE, STARTUP_WINDOW_MODE, + PORTAL_CONNECTIVITY_GUARD, store, VLC_PLAYER_ARGUMENTS, VLC_REUSE_INSTANCE, } from '../services/store.service'; import { httpServer } from '../server/http-server'; +import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard'; export default class SettingsEvents { static bootstrapSettingsEvents(): Electron.IpcMain { + setHostConnectivityGuardEnabled( + store.get(PORTAL_CONNECTIVITY_GUARD, true) !== false + ); return ipcMain; } } @@ -30,6 +35,12 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { redactSensitiveData(arg) ); + if (arg.portalConnectivityGuard !== undefined) { + const enabled = arg.portalConnectivityGuard !== false; + store.set(PORTAL_CONNECTIVITY_GUARD, enabled); + setHostConnectivityGuardEnabled(enabled); + } + if (arg.mpvPlayerArguments !== undefined) { store.set( MPV_PLAYER_ARGUMENTS, diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index 5600c4f86..d53338efb 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -25,6 +25,9 @@ export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY'; */ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE'; +/** Desktop portal request cooldown; absent means enabled. */ +export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD'; + /** * Extra libmpv options for embedded sessions, one "key=value" per line, as * typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler @@ -52,6 +55,7 @@ export type StoreType = { [EMBEDDED_MPV_EXTRA_OPTIONS]: string; [EMBEDDED_MPV_AUTO_RECONNECT]: boolean; [STARTUP_WINDOW_MODE]: StartupWindowMode; + [PORTAL_CONNECTIVITY_GUARD]: boolean; }; // Export singleton store instance diff --git a/apps/electron-backend/src/app/util/host-connectivity-guard.spec.ts b/apps/electron-backend/src/app/util/host-connectivity-guard.spec.ts index 85181baa6..2f791a9ae 100644 --- a/apps/electron-backend/src/app/util/host-connectivity-guard.spec.ts +++ b/apps/electron-backend/src/app/util/host-connectivity-guard.spec.ts @@ -9,6 +9,8 @@ import { beginGuardedHostRequest, reportGuardedHostFailure, resetHostConnectivityGuardForTests, + setHostConnectivityGuardEnabled, + reportGuardedHostSuccess, } from './host-connectivity-guard'; describe('reportGuardedHostFailure', () => { @@ -49,6 +51,62 @@ describe('reportGuardedHostFailure', () => { resetHostConnectivityGuardForTests(); }); + it('bypasses an open guard when disabled and starts fresh when re-enabled', () => { + attempt(ownFailure()); + attempt(ownFailure()); + expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow( + HostConnectivityGuardError + ); + setHostConnectivityGuardEnabled(false); + expect(beginGuardedHostRequest(URL_ON_ENDPOINT)).toBeNull(); + setHostConnectivityGuardEnabled(true); + expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow(); + attempt(ownFailure()); + expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow(); + }); + + it('does not reset the guard when saving an unchanged preference', () => { + attempt(ownFailure()); + attempt(ownFailure()); + setHostConnectivityGuardEnabled(true); + expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow( + HostConnectivityGuardError + ); + }); + + it('ignores old failures and successes after a preference transition', () => { + const oldFailure = beginGuardedHostRequest(URL_ON_ENDPOINT); + const oldSuccess = beginGuardedHostRequest(URL_ON_ENDPOINT); + setHostConnectivityGuardEnabled(false); + setHostConnectivityGuardEnabled(true); + reportGuardedHostFailure(oldFailure, ownFailure()); + reportGuardedHostFailure(oldFailure, ownFailure()); + expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow(); + attempt(ownFailure()); + attempt(ownFailure()); + reportGuardedHostSuccess(oldSuccess); + expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow( + HostConnectivityGuardError + ); + }); + + it('keeps the environment disable switch authoritative', () => { + const original = process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD; + process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD = '1'; + try { + setHostConnectivityGuardEnabled(true); + attempt(ownFailure()); + attempt(ownFailure()); + expect(() => + beginGuardedHostRequest(URL_ON_ENDPOINT) + ).not.toThrow(); + } finally { + if (original === undefined) + delete process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD; + else process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD = original; + } + }); + it('does not charge a same-origin redirect hop to the guarded endpoint', () => { // `/player_api.php` -> 302 -> `/slow/player_api.php` on the same origin: // the origin answered, so its record must clear even though the failing diff --git a/apps/electron-backend/src/app/util/host-connectivity-guard.ts b/apps/electron-backend/src/app/util/host-connectivity-guard.ts index 52c713244..2b0289a7b 100644 --- a/apps/electron-backend/src/app/util/host-connectivity-guard.ts +++ b/apps/electron-backend/src/app/util/host-connectivity-guard.ts @@ -22,6 +22,16 @@ export { HostConnectivityGuardError }; export type { HostRequestToken }; let sharedGuard: HostConnectivityGuard | null = null; +let enabled = true; +let currentTokens = new WeakSet(); + +/** Apply a saved desktop preference without letting old requests affect a new guard. */ +export function setHostConnectivityGuardEnabled(value: boolean): void { + if (enabled === value) return; + enabled = value; + sharedGuard = null; + currentTokens = new WeakSet(); +} /** The guard both portal IPC handlers share. */ export function getHostConnectivityGuard(): HostConnectivityGuard { @@ -40,6 +50,8 @@ export function getHostConnectivityGuard(): HostConnectivityGuard { /** Test seam: forgets the shared guard so each spec starts clean. */ export function resetHostConnectivityGuardForTests(): void { + enabled = true; + currentTokens = new WeakSet(); sharedGuard = null; } @@ -53,7 +65,7 @@ export function resetHostConnectivityGuardForTests(): void { */ export function beginGuardedHostRequest(url: string): HostRequestToken | null { const endpoint = portalEndpointKeyOf(url); - if (!endpoint) { + if (!endpoint || !enabled) { return null; } @@ -62,6 +74,7 @@ export function beginGuardedHostRequest(url: string): HostRequestToken | null { throw new HostConnectivityGuardError(endpoint); } + currentTokens.add(check.token); return check.token; } @@ -70,11 +83,14 @@ export function observeGuardedHostRequest( url: string ): HostRequestToken | null { const endpoint = portalEndpointKeyOf(url); - return endpoint ? getHostConnectivityGuard().observe(endpoint) : null; + if (!endpoint || !enabled) return null; + const token = getHostConnectivityGuard().observe(endpoint); + currentTokens.add(token); + return token; } export function reportGuardedHostSuccess(token: HostRequestToken | null): void { - if (token) { + if (token && currentTokens.has(token)) { getHostConnectivityGuard().reportSuccess(token); } } @@ -91,7 +107,7 @@ export function reportGuardedHostFailure( error: unknown, options: { countFailures?: boolean; requestUrl?: string } = {} ): void { - if (!token) { + if (!token || !currentTokens.has(token)) { return; } diff --git a/apps/web/src/app/settings/settings-form.utils.spec.ts b/apps/web/src/app/settings/settings-form.utils.spec.ts index de8010dc2..283733ae3 100644 --- a/apps/web/src/app/settings/settings-form.utils.spec.ts +++ b/apps/web/src/app/settings/settings-form.utils.spec.ts @@ -8,6 +8,16 @@ import { describe('settings form utils — strip country prefix', () => { const formBuilder = new FormBuilder(); + it('defaults portal request pauses on and preserves an explicit opt-out', () => { + const form = createSettingsForm(new FormBuilder(), true); + expect(form.get('portalConnectivityGuard')?.value).toBe(true); + form.patchValue({ portalConnectivityGuard: false }); + expect( + createSettingsFromFormValue(form, {} as Settings) + .portalConnectivityGuard + ).toBe(false); + }); + it('defaults the form control to false', () => { const form = createSettingsForm(formBuilder, true); diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index e689bd9e9..7cc11b783 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -107,6 +107,7 @@ export function createSettingsForm( embeddedMpvFrameCopy: false, embeddedMpvExtraOptions: ['', [embeddedMpvExtraOptionsValidator]], embeddedMpvAutoReconnect: true, + portalConnectivityGuard: true, coverSize: 'medium' as CoverSize, ...(supportsEpg ? { @@ -195,6 +196,7 @@ export function createSettingsFromFormValue( value.embeddedMpvExtraOptions ), embeddedMpvAutoReconnect: value.embeddedMpvAutoReconnect ?? true, + portalConnectivityGuard: value.portalConnectivityGuard !== false, coverSize: value.coverSize ?? 'medium', epgUrl, preferUploadedEpgOverXtream: diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html index 897fe7451..f50b4e60e 100644 --- a/apps/web/src/app/settings/settings-general-section.component.html +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -1,8 +1,4 @@ -
+
settings @@ -204,3 +200,48 @@
+@if (supportsPortalConnectivityGuard()) { +
+
+
+ hub +
+
+

{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}

+
+
+
+
+

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

+

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

+ @if (form().get('portalConnectivityGuard')?.value === false) { +

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

+ } +
+
+ +
+
+
+} diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index f03f1dbca..ae6a9a4a8 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -39,6 +39,7 @@ export class SettingsGeneralSectionComponent { input.required(); /** Desktop only: the window-mode select needs the main-process mirror and F11 */ readonly supportsStartupWindowMode = input(false); + readonly supportsPortalConnectivityGuard = input(false); readonly selectTheme = output(); readonly selectCoverSize = output(); diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index 110c575b9..fed7a1a91 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -59,6 +59,40 @@ describe('SettingsComponent form', () => { window.electron = originalElectron; }); + it('stages the desktop portal pause setting until Save', async () => { + const checkbox = (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="portal-connectivity-toggle"] input' + ) as HTMLInputElement; + expect(checkbox).not.toBeNull(); + expect(checkbox.checked).toBe(true); + checkbox.click(); + fixture.detectChanges(); + expect( + component.settingsForm.get('portalConnectivityGuard')?.value + ).toBe(false); + expect(window.electron.updateSettings).not.toHaveBeenCalled(); + component.form.hydrateFromStore(); + fixture.detectChanges(); + expect(checkbox.checked).toBe(true); + checkbox.click(); + await component.form.save(() => undefined); + expect(window.electron.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ portalConnectivityGuard: false }) + ); + }); + + it('hides the portal pause setting when the desktop bridge is unavailable', () => { + fixture.destroy(); + window.electron = undefined; + fixture = TestBed.createComponent(SettingsComponent); + fixture.detectChanges(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="portal-connectivity-setting"]' + ) + ).toBeNull(); + }); + describe('Get and set settings on component init', () => { const settings = { language: Language.GERMAN, diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 60e62513f..8b69c6fa5 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -111,6 +111,9 @@ [coverSizeOptions]="coverSizeOptions" [startupBehaviorOptions]="startupBehaviorOptions" [startupWindowModeOptions]="startupWindowModeOptions" + [supportsPortalConnectivityGuard]=" + supportsPortalConnectivityGuard + " [supportsStartupWindowMode]="supportsStartupWindowMode" (selectTheme)="form.selectTheme($event)" (selectCoverSize)="form.selectCoverSize($event)" diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index babf5c56f..8e1891377 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -142,6 +142,8 @@ export class SettingsComponent readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable; readonly supportsRemoteControl = this.runtime.supportsRemoteControl; readonly supportsStartupWindowMode = this.runtime.supportsStartupWindowMode; + readonly supportsPortalConnectivityGuard = + this.runtime.supportsPortalConnectivityGuard; /** Settings form object */ readonly settingsForm = this.form.form; diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index d4875a837..5627c742b 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -112,6 +112,7 @@ export const DEFAULT_SETTINGS = { embeddedMpvFrameCopy: false, embeddedMpvExtraOptions: '', embeddedMpvAutoReconnect: true, + portalConnectivityGuard: true, coverSize: 'medium', dashboardRails: DEFAULT_DASHBOARD_RAILS, preferUploadedEpgOverXtream: false, @@ -222,6 +223,9 @@ export function createEpgBridgeStub(): Partial { /** The full desktop bridge the settings page expects to be present */ export function createElectronStub(): typeof window.electron { return { + resetHostConnectivityGuard: jest + .fn() + .mockResolvedValue({ success: true }), checkEpgFreshness: jest.fn().mockResolvedValue({ freshUrls: [], staleUrls: [], diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 4e33d4818..3d636b102 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "اتصالات البوابات", + "PORTAL_CONNECTIVITY_GUARD": "إيقاف الطلبات إلى البوابات غير المتاحة مؤقتًا", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "بعد تكرار فشل الاتصال، تُوقف طلبات Xtream وStalker الجديدة لمدة 30 ثانية. جرّب تعطيل هذا الخيار إذا كانت البوابة تصبح غير متاحة بشكل متقطع. يُطبّق بعد الحفظ دون إعادة التشغيل.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "سيُنفّذ كل طلب بشكل مستقل. قد يستغرق ظهور الخطأ وقتًا أطول عند عدم توفر الخادم.", "TITLE": "الإعدادات", "GENERAL": "عام", "GENERAL_SUBTITLE": "تغيير تكوين التطبيق", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "الطلبات متوقفة مؤقتًا", + "DESCRIPTION": "أُوقفت الطلبات بعد تكرار فشل الاتصال. أعد المحاولة للاتصال بالبوابة فورًا.", + "RETRY": "إعادة المحاولة الآن" + }, "ALL_CATEGORIES": "جميع الفئات", "MY_FAVORITES": "المفضلة", "ADD_TO_FAVORITES": "إضافة للمفضلة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 7ff2c5f33..32cf398a1 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "الاتصال بالبوابات", + "PORTAL_CONNECTIVITY_GUARD": "وقّف مؤقتاً الطلبات للبوابات اللي ما متاحاش", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "من بعد ما يفشل الاتصال عدة مرات، كيوقف طلبات Xtream وStalker الجديدة لمدة 30 ثانية. جرّب تطفي هاد الخيار إلا كانت البوابة كتقطع مرة مرة. كيتطبق من بعد الحفظ بلا إعادة التشغيل.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "كل طلب غادي يتنفذ بوحدو. إلا كان السيرفر ما متاحش، رسالة الخطأ تقدر تتأخر.", "TITLE": "الإعدادات", "GENERAL": "عام", "GENERAL_SUBTITLE": "بدل تكوين التطبيق", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "الطلبات موقوفة مؤقتاً", + "DESCRIPTION": "توقفات الطلبات من بعد ما فشل الاتصال عدة مرات. عاود جرّب باش تتاصل بالبوابة دابا.", + "RETRY": "عاود جرّب دابا" + }, "ALL_CATEGORIES": "جميع الفئات", "MY_FAVORITES": "المفضلة ديالي", "ADD_TO_FAVORITES": "زيد للمفضلة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index af8641217..77f57d576 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Падключэнне да парталаў", + "PORTAL_CONNECTIVITY_GUARD": "Прыпыняць запыты да недаступных парталаў", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Пасля паўторных памылак падключэння прыпыняць новыя запыты Xtream і Stalker на 30 секунд. Паспрабуйце выключыць, калі партал перыядычна становіцца недаступным. Ужываецца пасля захавання без перазапуску.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Кожны запыт будзе выконвацца незалежна. Калі сервер недаступны, чаканне памылкі можа павялічыцца.", "TITLE": "Налады", "GENERAL": "Агульныя", "GENERAL_SUBTITLE": "Змяніце канфігурацыю праграмы", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Запыты часова прыпынены", + "DESCRIPTION": "Запыты прыпынены пасля паўторных памылак падключэння. Паўтарыце спробу, каб звярнуцца да партала зараз.", + "RETRY": "Паўтарыць зараз" + }, "ALL_CATEGORIES": "Усе катэгорыі", "MY_FAVORITES": "Выбранае", "ADD_TO_FAVORITES": "Дадаць у выбранае", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 4ad91e64a..224cd854f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Portalverbindungen", + "PORTAL_CONNECTIVITY_GUARD": "Anfragen an nicht erreichbare Portale pausieren", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Nach wiederholten Verbindungsfehlern neue Xtream- und Stalker-Anfragen 30 Sekunden pausieren. Bei zeitweise nicht erreichbaren Portalen versuchsweise deaktivieren. Gilt nach dem Speichern ohne Neustart.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Jede Anfrage wird unabhängig ausgeführt. Bei nicht erreichbaren Servern kann die Fehlermeldung länger dauern.", "TITLE": "Einstellungen", "GENERAL": "Allgemein", "GENERAL_SUBTITLE": "Allgemeine App-Einstellungen ändern", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Anfragen vorübergehend pausiert", + "DESCRIPTION": "Anfragen wurden nach wiederholten Verbindungsfehlern pausiert. Jetzt erneut versuchen, um das Portal sofort zu kontaktieren.", + "RETRY": "Jetzt erneut versuchen" + }, "ALL_CATEGORIES": "Alle Kategorien", "MY_FAVORITES": "Meine Favoriten", "ADD_TO_FAVORITES": "Zu den Favoriten hinzufügen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index d379e9dcb..ead1eba01 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Συνδέσεις πυλών", + "PORTAL_CONNECTIVITY_GUARD": "Παύση αιτημάτων προς μη διαθέσιμες πύλες", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Μετά από επανειλημμένα σφάλματα σύνδεσης, παύση νέων αιτημάτων Xtream και Stalker για 30 δευτερόλεπτα. Δοκιμάστε να το απενεργοποιήσετε αν μια πύλη είναι κατά διαστήματα μη διαθέσιμη. Ισχύει μετά την αποθήκευση, χωρίς επανεκκίνηση.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Κάθε αίτημα εκτελείται ανεξάρτητα. Οι μη διαθέσιμοι διακομιστές μπορεί να αργούν περισσότερο να αναφέρουν σφάλμα.", "TITLE": "Ρυθμίσεις", "GENERAL": "Γενικά", "GENERAL_SUBTITLE": "Αλλαγή της διαμόρφωσης της εφαρμογής", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Τα αιτήματα έχουν παύσει προσωρινά", + "DESCRIPTION": "Τα αιτήματα σταμάτησαν μετά από επανειλημμένα σφάλματα σύνδεσης. Δοκιμάστε ξανά για άμεση επικοινωνία με την πύλη.", + "RETRY": "Νέα προσπάθεια τώρα" + }, "ALL_CATEGORIES": "Όλες οι κατηγορίες", "MY_FAVORITES": "Τα αγαπημένα μου", "ADD_TO_FAVORITES": "Προσθήκη στα αγαπημένα", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 0464b6b39..432955f05 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Portal connections", + "PORTAL_CONNECTIVITY_GUARD": "Pause requests to unavailable portals", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "After repeated connection failures, pause new Xtream and Stalker requests for 30 seconds. Try disabling this if a portal becomes unavailable intermittently. Applies after saving, without restarting.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Each request will run independently. Unavailable servers may take longer to report an error.", "TITLE": "Settings", "GENERAL": "General", "GENERAL_SUBTITLE": "Change the configuration of the application", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Requests temporarily paused", + "DESCRIPTION": "Requests were paused after repeated connection failures. Retry now to contact the portal immediately.", + "RETRY": "Retry now" + }, "ALL_CATEGORIES": "All categories", "MY_FAVORITES": "My favorites", "ADD_TO_FAVORITES": "Add to favorites", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 09161cf36..29d9bc536 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Conexiones a portales", + "PORTAL_CONNECTIVITY_GUARD": "Pausar solicitudes a portales no disponibles", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tras varios fallos de conexión, pausar las nuevas solicitudes de Xtream y Stalker durante 30 segundos. Prueba a desactivarlo si un portal deja de estar disponible de forma intermitente. Se aplica al guardar, sin reiniciar.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada solicitud se ejecutará de forma independiente. Los servidores no disponibles pueden tardar más en mostrar un error.", "TITLE": "Ajustes", "GENERAL": "General", "GENERAL_SUBTITLE": "Cambia la configuracion general de la aplicacion", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Solicitudes pausadas temporalmente", + "DESCRIPTION": "Las solicitudes se pausaron tras varios fallos de conexión. Reintenta para contactar con el portal inmediatamente.", + "RETRY": "Reintentar ahora" + }, "ALL_CATEGORIES": "Todas las categorías", "MY_FAVORITES": "Mis favoritos", "ADD_TO_FAVORITES": "Agregar a los favoritos", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 38f22a43f..524b76070 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Connexions aux portails", + "PORTAL_CONNECTIVITY_GUARD": "Suspendre les requêtes aux portails indisponibles", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Après plusieurs échecs de connexion, suspendre les nouvelles requêtes Xtream et Stalker pendant 30 secondes. Essayez de désactiver cette option si un portail devient indisponible par intermittence. Prend effet après enregistrement, sans redémarrage.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Chaque requête sera exécutée indépendamment. Les serveurs indisponibles peuvent mettre plus de temps à signaler une erreur.", "TITLE": "Paramètres", "GENERAL": "Général", "GENERAL_SUBTITLE": "Modifier la configuration de l'application", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Requêtes temporairement suspendues", + "DESCRIPTION": "Les requêtes ont été suspendues après plusieurs échecs de connexion. Réessayez pour contacter le portail immédiatement.", + "RETRY": "Réessayer maintenant" + }, "ALL_CATEGORIES": "Toutes les catégories", "MY_FAVORITES": "Mes favoris", "ADD_TO_FAVORITES": "Ajouter aux favoris", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 5694503ad..24a6d9828 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Portálkapcsolatok", + "PORTAL_CONNECTIVITY_GUARD": "Kérések szüneteltetése az elérhetetlen portálokhoz", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Ismételt kapcsolódási hibák után az új Xtream- és Stalker-kérések szüneteltetése 30 másodpercre. Ha egy portál időnként elérhetetlenné válik, próbálja kikapcsolni. Mentés után, újraindítás nélkül érvényesül.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Minden kérés önállóan fut. Elérhetetlen szervereknél a hiba megjelenése tovább tarthat.", "TITLE": "Beállítások", "GENERAL": "Általános", "GENERAL_SUBTITLE": "Az alkalmazás beállításainak módosítása", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Kérések átmenetileg szüneteltetve", + "DESCRIPTION": "A kérések ismételt kapcsolódási hibák után szünetelnek. Próbálja újra a portál azonnali eléréséhez.", + "RETRY": "Újrapróbálkozás most" + }, "ALL_CATEGORIES": "Összes kategória", "MY_FAVORITES": "Saját kedvencek", "ADD_TO_FAVORITES": "Hozzáadás a kedvencekhez", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 94b15d7f4..5809d7a18 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Connessioni ai portali", + "PORTAL_CONNECTIVITY_GUARD": "Sospendi le richieste ai portali non disponibili", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Dopo ripetuti errori di connessione, sospendi le nuove richieste Xtream e Stalker per 30 secondi. Prova a disattivare se un portale diventa intermittentemente non disponibile. Si applica al salvataggio, senza riavvio.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Ogni richiesta verrà eseguita indipendentemente. I server non disponibili potrebbero impiegare più tempo a segnalare un errore.", "TITLE": "Impostazioni IPTVnator", "GENERAL": "Generale", "GENERAL_SUBTITLE": "Modifica le impostazioni generali dell'applicazione", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Richieste temporaneamente sospese", + "DESCRIPTION": "Le richieste sono state sospese dopo ripetuti errori di connessione. Riprova per contattare subito il portale.", + "RETRY": "Riprova ora" + }, "ALL_CATEGORIES": "Tutte le categorie", "MY_FAVORITES": "I miei preferiti", "ADD_TO_FAVORITES": "Aggiungi ai preferiti", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index a7b8c555f..18f3dc14d 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "ポータル接続", + "PORTAL_CONNECTIVITY_GUARD": "利用できないポータルへのリクエストを一時停止", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "接続エラーが繰り返された場合、新しい Xtream と Stalker のリクエストを30秒間停止します。ポータルが断続的に利用できなくなる場合は、無効にしてみてください。保存後、再起動せずに適用されます。", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "各リクエストは個別に実行されます。サーバーが利用できない場合、エラーの表示に時間がかかることがあります。", "TITLE": "設定", "GENERAL": "一般", "GENERAL_SUBTITLE": "アプリケーションの設定を変更", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "リクエストを一時停止中", + "DESCRIPTION": "接続エラーが繰り返されたため、リクエストを停止しました。今すぐ再試行すると、ポータルに直ちに接続します。", + "RETRY": "今すぐ再試行" + }, "ALL_CATEGORIES": "すべてのカテゴリー", "MY_FAVORITES": "お気に入り", "ADD_TO_FAVORITES": "お気に入りに追加", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 120b074e8..c43a4cc8f 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "포털 연결", + "PORTAL_CONNECTIVITY_GUARD": "사용할 수 없는 포털에 대한 요청 일시 중지", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "연결 오류가 반복되면 새 Xtream 및 Stalker 요청을 30초 동안 중지합니다. 포털을 간헐적으로 사용할 수 없다면 이 옵션을 꺼 보세요. 저장 후 재시작 없이 적용됩니다.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "각 요청이 독립적으로 실행됩니다. 서버를 사용할 수 없으면 오류가 표시되기까지 더 오래 걸릴 수 있습니다.", "TITLE": "설정", "GENERAL": "일반", "GENERAL_SUBTITLE": "애플리케이션 구성을 변경합니다", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "요청이 일시 중지됨", + "DESCRIPTION": "연결 오류가 반복되어 요청이 중지되었습니다. 지금 다시 시도하여 포털에 즉시 연결하세요.", + "RETRY": "지금 다시 시도" + }, "ALL_CATEGORIES": "모든 카테고리", "MY_FAVORITES": "내가 좋아하는 것들", "ADD_TO_FAVORITES": "즐겨 찾기에 추가", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 1070bdc97..8ea848b18 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Portalverbindingen", + "PORTAL_CONNECTIVITY_GUARD": "Verzoeken aan onbereikbare portals pauzeren", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Pauzeer nieuwe Xtream- en Stalker-verzoeken 30 seconden na herhaalde verbindingsfouten. Probeer dit uit te schakelen als een portal af en toe onbereikbaar is. Geldt na opslaan, zonder herstart.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Elk verzoek wordt onafhankelijk uitgevoerd. Bij onbereikbare servers kan een foutmelding langer duren.", "TITLE": "Instellingen", "GENERAL": "Algemeen", "GENERAL_SUBTITLE": "Wijzig de configuratie van de applicatie", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Verzoeken tijdelijk gepauzeerd", + "DESCRIPTION": "Verzoeken zijn gepauzeerd na herhaalde verbindingsfouten. Probeer opnieuw om de portal direct te bereiken.", + "RETRY": "Nu opnieuw proberen" + }, "ALL_CATEGORIES": "Alle categorieën", "MY_FAVORITES": "Mijn favorieten", "ADD_TO_FAVORITES": "Voeg toe aan favorieten", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ca75a2d34..12df20fc4 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Połączenia z portalami", + "PORTAL_CONNECTIVITY_GUARD": "Wstrzymuj żądania do niedostępnych portali", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Po powtarzających się błędach połączenia wstrzymuj nowe żądania Xtream i Stalker na 30 sekund. Spróbuj wyłączyć, jeśli portal bywa niedostępny. Zmiana działa po zapisaniu, bez ponownego uruchomienia.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Każde żądanie będzie wykonywane niezależnie. Niedostępne serwery mogą zgłaszać błąd z większym opóźnieniem.", "TITLE": "Ustawienia", "GENERAL": "Ogólne", "GENERAL_SUBTITLE": "Zmień konfigurację aplikacji", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Żądania tymczasowo wstrzymane", + "DESCRIPTION": "Żądania wstrzymano po powtarzających się błędach połączenia. Ponów próbę, aby natychmiast połączyć się z portalem.", + "RETRY": "Ponów teraz" + }, "ALL_CATEGORIES": "Wszystkie kategorie", "MY_FAVORITES": "Moje ulubione", "ADD_TO_FAVORITES": "Dodaj do ulubionych", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 70a33ef7b..550d63356 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Ligações aos portais", + "PORTAL_CONNECTIVITY_GUARD": "Pausar pedidos a portais indisponíveis", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Após falhas repetidas de ligação, pausar novos pedidos Xtream e Stalker durante 30 segundos. Experimente desativar se um portal ficar indisponível de forma intermitente. Aplica-se ao guardar, sem reiniciar.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada pedido será executado de forma independente. Os servidores indisponíveis podem demorar mais a indicar um erro.", "TITLE": "Configurações", "GENERAL": "Geral", "GENERAL_SUBTITLE": "Altere a configuração do aplicativo", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Pedidos temporariamente pausados", + "DESCRIPTION": "Os pedidos foram pausados após falhas repetidas de ligação. Tente novamente para contactar o portal imediatamente.", + "RETRY": "Tentar novamente agora" + }, "ALL_CATEGORIES": "Todas as categorias", "MY_FAVORITES": "Meus favoritos", "ADD_TO_FAVORITES": "Adicionar aos favoritos", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 77768e2a2..d4eaf73f6 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Подключение к порталам", + "PORTAL_CONNECTIVITY_GUARD": "Приостанавливать запросы к недоступным порталам", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "После повторных ошибок подключения приостанавливать новые запросы Xtream и Stalker на 30 секунд. Попробуйте отключить, если портал периодически становится недоступен. Применяется после сохранения без перезапуска.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Каждый запрос будет выполняться независимо. Если сервер недоступен, ожидание ошибки может увеличиться.", "TITLE": "Настройки", "GENERAL": "Общие", "GENERAL_SUBTITLE": "Изменение основных параметров приложения", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "Запросы временно приостановлены", + "DESCRIPTION": "Запросы приостановлены после повторных ошибок подключения. Повторите попытку, чтобы обратиться к порталу сейчас.", + "RETRY": "Повторить сейчас" + }, "ALL_CATEGORIES": "Все категории", "MY_FAVORITES": "Мои избранные", "ADD_TO_FAVORITES": "Добавить в избранное", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7c21b8ccb..8f8f17907 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "Portal bağlantıları", + "PORTAL_CONNECTIVITY_GUARD": "Kullanılamayan portallara istekleri duraklat", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tekrarlanan bağlantı hatalarından sonra yeni Xtream ve Stalker isteklerini 30 saniye duraklatır. Portal zaman zaman kullanılamıyorsa kapatmayı deneyin. Kaydettikten sonra yeniden başlatmadan uygulanır.", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Her istek bağımsız yürütülür. Kullanılamayan sunucularda hata bildirimi daha uzun sürebilir.", "TITLE": "Ayarlar", "GENERAL": "Genel", "GENERAL_SUBTITLE": "Uygulamanın yapılandırmasını değiştirin", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "İstekler geçici olarak duraklatıldı", + "DESCRIPTION": "Tekrarlanan bağlantı hatalarından sonra istekler duraklatıldı. Portala hemen bağlanmak için yeniden deneyin.", + "RETRY": "Şimdi yeniden dene" + }, "ALL_CATEGORIES": "Tüm kategoriler", "MY_FAVORITES": "Favorilerim", "ADD_TO_FAVORITES": "Favorilere ekle", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 466b15dea..ce1919afa 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "门户连接", + "PORTAL_CONNECTIVITY_GUARD": "暂停向不可用门户发送请求", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "连接连续失败后,暂停新的 Xtream 和 Stalker 请求 30 秒。如果门户间歇性不可用,请尝试关闭此选项。保存后生效,无需重启。", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "每个请求将独立执行。服务器不可用时,可能需要更长时间才能显示错误。", "TITLE": "设置", "GENERAL": "通用", "GENERAL_SUBTITLE": "更改应用程序的配置", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "请求已暂时暂停", + "DESCRIPTION": "连接连续失败后,请求已暂停。立即重试以重新连接门户。", + "RETRY": "立即重试" + }, "ALL_CATEGORIES": "所有分类", "MY_FAVORITES": "我的收藏", "ADD_TO_FAVORITES": "添加到收藏夹", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 7f277bf24..d5707cc40 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -244,6 +244,10 @@ } }, "SETTINGS": { + "PORTAL_CONNECTIONS": "入口網站連線", + "PORTAL_CONNECTIVITY_GUARD": "暫停向無法使用的入口網站傳送請求", + "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "連線連續失敗後,暫停新的 Xtream 和 Stalker 請求 30 秒。如果入口網站間歇性無法使用,請嘗試關閉此選項。儲存後生效,無需重新啟動。", + "PORTAL_CONNECTIVITY_GUARD_DISABLED": "每個請求將獨立執行。伺服器無法使用時,可能需要更長時間才能顯示錯誤。", "TITLE": "設定", "GENERAL": "一般", "GENERAL_SUBTITLE": "更改應用程式的配置", @@ -1002,6 +1006,11 @@ } }, "PORTALS": { + "REQUESTS_PAUSED": { + "TITLE": "請求已暫時暫停", + "DESCRIPTION": "連線連續失敗後,請求已暫停。立即重試以重新連線至入口網站。", + "RETRY": "立即重試" + }, "ALL_CATEGORIES": "所有類別", "MY_FAVORITES": "我的最愛", "ADD_TO_FAVORITES": "新增至最愛", diff --git a/docs/architecture/host-connectivity-guard.md b/docs/architecture/host-connectivity-guard.md index 3ee0f8ba9..63f9a0190 100644 --- a/docs/architecture/host-connectivity-guard.md +++ b/docs/architecture/host-connectivity-guard.md @@ -69,6 +69,35 @@ timeouts but not the breaker, deliberately: idle-based, see above — which outlives the 45 s half-open trial expiry and would let a second trial in behind the first. +## Desktop preference and account feedback + +Settings > General > Portal connections offers **Pause requests to unavailable +portals**, enabled by default. `Settings.portalConnectivityGuard` is default-on +for missing or non-boolean legacy values; only explicit `false` opts out. The +form stages edits until Save and persists the choice through the normal settings +store. `SETTINGS_UPDATE` mirrors it to Electron's `PORTAL_CONNECTIVITY_GUARD` +config key and applies it immediately. `SettingsEvents.bootstrapSettingsEvents` +loads that mirror before the renderer is loaded, so a saved opt-out already +applies to the first portal request after restarting. + +The preference controls both Xtream and Stalker. A real preference transition +replaces the Electron guard and its weak set of admitted request tokens, clearing +existing cooldowns and ignoring completions from the old generation. Disabled +requests return no token and cannot contribute failures when the user re-enables +the guard. Saving an unchanged value preserves existing evidence. +`IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` (or `true`) still overrides an enabled +preference. The UI is capability-gated by `supportsPortalConnectivityGuard` +(`updateSettings` plus `resetHostConnectivityGuard`); the PWA has no client-side +switch for its shared server-wide guard. + +Both account-info dialogs classify the existing cross-process guard message and +show a localized **Requests temporarily paused** explanation with **Retry now**. +Stalker keeps cached account data visible and offers the same retry beside the +paused refresh notice. Retry resets before requesting again; actual network +failures retain the generic unavailable state. These notices describe the last +request outcome, not a live countdown. The preference does not fix the separate +same-millisecond sibling-counting issue #1438. + ## Rules Being wrong here means refusing to talk to a portal that works, so every rule diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.html b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.html index 282e971db..e1f25eb1f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.html @@ -46,7 +46,17 @@ @if (refreshFailed()) { } @else if (loadState() === 'loading') {

- {{ 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' | translate }} + {{ + (requestsPaused() + ? 'PORTALS.REQUESTS_PAUSED.TITLE' + : 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' + ) | translate + }}

{{ - 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION' - | translate + (requestsPaused() + ? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION' + : 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION' + ) | translate }}

} @else if (loadState() === 'loading') { diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts index 789380c13..4f20105f8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts @@ -84,6 +84,33 @@ describe('StalkerAccountInfoComponent', () => { }; }); + it.each([false, true])( + 'explains a paused refresh with cached snapshot=%s and allows retry', + async (cached) => { + if (cached) + playlistsService.getPlaylist.mockReturnValue( + of({ ...playlist, stalkerAccountInfo: cachedSnapshot }) + ); + accountInfoService.fetchAccountInfo.mockRejectedValueOnce( + new Error( + "Error invoking remote method 'STALKER_REQUEST': Portal http://portal.example is not responding; skipped after repeated connection failures" + ) + ); + await createComponent(); + expect(component.requestsPaused()).toBe(true); + expect(component.isCachedSnapshot()).toBe(cached); + expect(fixture.nativeElement.textContent).toContain( + 'PORTALS.REQUESTS_PAUSED.DESCRIPTION' + ); + expect(fixture.nativeElement.textContent).toContain( + 'PORTALS.REQUESTS_PAUSED.RETRY' + ); + await component.reload(); + expect(component.requestsPaused()).toBe(false); + expect(component.refreshFailed()).toBe(false); + } + ); + it('shows the fresh snapshot when the portal answers', async () => { await createComponent(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts index eb95f5d1a..711fc7262 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts @@ -23,6 +23,7 @@ import { resetHostConnectivityGuard, } from '@iptvnator/services'; import { + isHostConnectivityFastFailMessage, PlaylistMeta, type StalkerAccountInfoDialogData, } from '@iptvnator/shared/interfaces'; @@ -56,9 +57,8 @@ const DAY_IN_MS = 86_400_000; changeDetection: ChangeDetectionStrategy.OnPush, }) export class StalkerAccountInfoComponent { - private readonly data = inject( - MAT_DIALOG_DATA - ); + private readonly data = + inject(MAT_DIALOG_DATA); private readonly accountInfoService = inject(StalkerAccountInfoService); private readonly dataService = inject(DataService); private readonly playlistsService = inject(PlaylistsService); @@ -72,6 +72,7 @@ export class StalkerAccountInfoComponent { */ readonly isFullPortal = isFullStalkerPortalPlaylist(this.playlist); readonly loadState = signal('loading'); + readonly requestsPaused = signal(false); readonly snapshot = signal(null); readonly snapshotSource = signal(null); readonly refreshFailed = signal(false); @@ -242,6 +243,7 @@ export class StalkerAccountInfoComponent { } private async load(): Promise { + this.requestsPaused.set(false); this.refreshFailed.set(false); if (!this.snapshot()) { this.loadState.set('loading'); @@ -259,6 +261,11 @@ export class StalkerAccountInfoComponent { } this.loadState.set('ready'); } catch (error) { + this.requestsPaused.set( + isHostConnectivityFastFailMessage( + error instanceof Error ? error.message : error + ) + ); this.logger.error('Failed to fetch account info', error); if (this.snapshot()) { // Keep showing the cached snapshot; just flag the refresh. diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.html b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.html index 4cbafd473..d7f9af99f 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.html +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.html @@ -91,27 +91,43 @@ signal_wifi_connected_no_internet_4

- {{ 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' | translate }} + {{ + (requestsPaused() + ? 'PORTALS.REQUESTS_PAUSED.TITLE' + : 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' + ) | translate + }}

{{ - 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION' - | translate + (requestsPaused() + ? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION' + : 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION' + ) | translate }}

} @else if (loadState() === 'loading') { diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts index 197cab058..da4ff61ff 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts @@ -84,6 +84,38 @@ describe('AccountInfoComponent', () => { await fixture.whenStable(); }); + it('explains a guard refusal and clears the notice on successful retry', async () => { + xtreamApiService.getAccountInfo.mockRejectedValueOnce( + new Error( + "Error invoking remote method 'XTREAM_REQUEST': Portal https://portal.example is not responding; skipped after repeated connection failures" + ) + ); + await component.reload(); + fixture.detectChanges(); + expect(component.requestsPaused()).toBe(true); + expect(fixture.nativeElement.textContent).toContain( + 'PORTALS.REQUESTS_PAUSED.TITLE' + ); + expect(fixture.nativeElement.textContent).toContain( + 'PORTALS.REQUESTS_PAUSED.RETRY' + ); + await component.reload(); + expect(component.requestsPaused()).toBe(false); + expect(component.loadState()).toBe('ready'); + }); + + it('keeps actual network errors distinct from a paused request', async () => { + xtreamApiService.getAccountInfo.mockRejectedValueOnce( + new Error('connect ECONNREFUSED') + ); + await component.reload(); + fixture.detectChanges(); + expect(component.requestsPaused()).toBe(false); + expect(fixture.nativeElement.textContent).toContain( + 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' + ); + }); + it('loads account info from dialog-supplied playlist credentials', () => { expect(xtreamApiService.getAccountInfo).toHaveBeenCalledWith({ serverUrl: 'https://dialog.example.test', diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts index bbe911862..8ef92c7b4 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts @@ -15,11 +15,9 @@ import { XtreamStore, } from '@iptvnator/portal/xtream/data-access'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { DataService, resetHostConnectivityGuard } from '@iptvnator/services'; import { - DataService, - resetHostConnectivityGuard, -} from '@iptvnator/services'; -import { + isHostConnectivityFastFailMessage, resolveXtreamPortalStatus, type XtreamAccountInfoDialogData, } from '@iptvnator/shared/interfaces'; @@ -67,6 +65,7 @@ export class AccountInfoComponent { () => this.data.playlist ?? this.xtreamStore.currentPlaylist() ); readonly loadState = signal('loading'); + readonly requestsPaused = signal(false); readonly accountInfo = signal(null); readonly skeletonStats = [1, 2, 3, 4]; readonly skeletonPanels = [1, 2]; @@ -241,6 +240,7 @@ export class AccountInfoComponent { } private async load(): Promise { + this.requestsPaused.set(false); const playlist = this.currentPlaylist(); if (!playlist?.serverUrl || !playlist.username || !playlist.password) { @@ -261,6 +261,11 @@ export class AccountInfoComponent { this.accountInfo.set(accountInfo); this.loadState.set('ready'); } catch (error) { + this.requestsPaused.set( + isHostConnectivityFastFailMessage( + error instanceof Error ? error.message : error + ) + ); this.logger.error('Failed to fetch account info', error); this.accountInfo.set(null); this.loadState.set('error'); diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 88e2a0fb2..7f23b4cf6 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -10,6 +10,16 @@ describe('RuntimeCapabilitiesService', () => { testWindow.electron = originalElectron; }); + it('offers the portal cooldown setting only with both desktop bridge methods', () => { + const service = new RuntimeCapabilitiesService(); + testWindow.electron = { updateSettings: jest.fn() }; + expect(service.supportsPortalConnectivityGuard).toBe(false); + testWindow.electron['resetHostConnectivityGuard'] = jest.fn(); + expect(service.supportsPortalConnectivityGuard).toBe(true); + testWindow.electron = undefined; + expect(service.supportsPortalConnectivityGuard).toBe(false); + }); + it('reports browser PWA capabilities when the Electron bridge is absent', () => { testWindow.electron = undefined; diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 939227817..c055492a0 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -69,6 +69,14 @@ export class RuntimeCapabilitiesService { ); } + /** Desktop-only preference: PWA clients cannot configure the shared server guard. */ + get supportsPortalConnectivityGuard(): boolean { + return ( + this.hasElectronMethod('updateSettings') && + this.hasElectronMethod('resetHostConnectivityGuard') + ); + } + /** * Desktop window mode at launch (normal / maximized / fullscreen) plus * the F11 toggle. The mode reaches the main process through diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index d60e72d12..aaa96dd3a 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -54,6 +54,25 @@ describe('SettingsStore dashboard rail settings', () => { }); }); + it('defaults portal request pauses on and persists an explicit opt-out', async () => { + const store = injector.get(SettingsStore); + expect(store.getSettings().portalConnectivityGuard).toBe(true); + await store.updateSettings({ portalConnectivityGuard: false }); + expect(store.getSettings().portalConnectivityGuard).toBe(false); + }); + + it('restores a portal pause opt-out and keeps it through partial updates', async () => { + storedSettings = { portalConnectivityGuard: false }; + const store = injector.get(SettingsStore); + await store.loadSettings(); + await store.updateSettings({ showCaptions: true }); + expect(store.getSettings().portalConnectivityGuard).toBe(false); + expect(storage.set).toHaveBeenLastCalledWith( + STORE_KEY.Settings, + expect.objectContaining({ portalConnectivityGuard: false }) + ); + }); + it('shares the pending initial settings load across startup waiters', async () => { const pendingSettings = new Subject | null>(); storage.get.mockReturnValue(pendingSettings.asObservable()); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 342015ccd..dd392a44d 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -57,6 +57,7 @@ const DEFAULT_SETTINGS: Settings = { embeddedMpvFrameCopy: false, embeddedMpvExtraOptions: '', embeddedMpvAutoReconnect: true, + portalConnectivityGuard: true, coverSize: 'medium', epgViewMode: 'timeline', epgOffsetMinutes: 0, @@ -169,6 +170,9 @@ export const SettingsStore = signalStore( webPlayerSharedControls: storedSettings.webPlayerSharedControls !== false, + portalConnectivityGuard: + storedSettings.portalConnectivityGuard !== + false, embeddedMpvAutoReconnect: storedSettings.embeddedMpvAutoReconnect !== false, @@ -206,6 +210,12 @@ export const SettingsStore = signalStore( settings.webPlayerSharedControls !== false, } : {}), + ...(settings.portalConnectivityGuard !== undefined + ? { + portalConnectivityGuard: + settings.portalConnectivityGuard !== false, + } + : {}), ...(settings.embeddedMpvAutoReconnect !== undefined ? { embeddedMpvAutoReconnect: @@ -298,6 +308,8 @@ export const SettingsStore = signalStore( store.embeddedMpvFrameCopy?.() ?? false, embeddedMpvExtraOptions: store.embeddedMpvExtraOptions?.() ?? '', + portalConnectivityGuard: + store.portalConnectivityGuard?.() !== false, embeddedMpvAutoReconnect: store.embeddedMpvAutoReconnect?.() !== false, coverSize: diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 11196dee3..9074622a8 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -229,6 +229,8 @@ export interface Settings { * Captured per session, so a change applies to the next session. */ embeddedMpvAutoReconnect?: boolean; + /** Desktop Xtream/Stalker request cooldown. Default on; applies on Save. */ + portalConnectivityGuard?: boolean; /** Cover/poster sizing preset applied across grids and rails */ coverSize?: CoverSize; /** Live EPG panel layout: horizontal timeline (default) or vertical list */