diff --git a/apps/web/src/app/server-storage/server-instance-probe.spec.ts b/apps/web/src/app/server-storage/server-instance-probe.spec.ts index 2fd8e3bee..9add42ae5 100644 --- a/apps/web/src/app/server-storage/server-instance-probe.spec.ts +++ b/apps/web/src/app/server-storage/server-instance-probe.spec.ts @@ -9,74 +9,79 @@ const instance = { }; describe('probeServerInstance', () => { - const ok = (body: unknown) => - jest.fn().mockResolvedValue({ ok: true, json: async () => body }); + const answer = (body: unknown, status = 200) => + jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); + const config = { + runtimeConfig: { BACKEND_URL: '/api' }, + isElectron: false, + }; it('skips the probe without a runtime backend URL', async () => { - const fetchFn = ok(instance); + const fetchFn = answer(instance); await expect( probeServerInstance({ runtimeConfig: {}, fetchFn: fetchFn as never, }) - ).resolves.toBeNull(); + ).resolves.toEqual({ kind: 'skipped' }); expect(fetchFn).not.toHaveBeenCalled(); }); it('skips the probe in Electron', async () => { - const fetchFn = ok(instance); + const fetchFn = answer(instance); await expect( probeServerInstance({ - runtimeConfig: { BACKEND_URL: '/api' }, + ...config, isElectron: true, fetchFn: fetchFn as never, }) - ).resolves.toBeNull(); + ).resolves.toEqual({ kind: 'skipped' }); expect(fetchFn).not.toHaveBeenCalled(); }); it('returns the instance when it reports server storage', async () => { - const fetchFn = ok(instance); + const fetchFn = answer(instance); await expect( probeServerInstance({ runtimeConfig: { BACKEND_URL: '/api/' }, isElectron: false, fetchFn: fetchFn as never, }) - ).resolves.toEqual(instance); + ).resolves.toEqual({ kind: 'server', instance }); expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance'); }); - it('returns null for storage none, bad bodies and failures', async () => { - const config = { - runtimeConfig: { BACKEND_URL: '/api' }, - isElectron: false, - }; - await expect( - probeServerInstance({ - ...config, - fetchFn: ok({ ...instance, storage: 'none' }) as never, - }) - ).resolves.toBeNull(); - await expect( - probeServerInstance({ - ...config, - fetchFn: ok({ hello: 1 }) as never, - }) - ).resolves.toBeNull(); - await expect( - probeServerInstance({ - ...config, - fetchFn: jest - .fn() - .mockResolvedValue({ ok: false, status: 404 }) as never, - }) - ).resolves.toBeNull(); - await expect( - probeServerInstance({ - ...config, - fetchFn: jest.fn().mockRejectedValue(new Error('x')) as never, - }) - ).resolves.toBeNull(); + it('reports no server storage for an answer without it', async () => { + for (const fetchFn of [ + answer({ ...instance, storage: 'none' }), + answer({ hello: 1 }), + answer({ error: 'not found' }, 404), + jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('not json'); + }, + }), + ]) { + await expect( + probeServerInstance({ ...config, fetchFn: fetchFn as never }) + ).resolves.toEqual({ kind: 'none' }); + } + }); + + it('reports an unreachable server for failures and 5xx', async () => { + for (const fetchFn of [ + jest.fn().mockRejectedValue(new TypeError('Failed to fetch')), + answer('Bad Gateway', 502), + ]) { + await expect( + probeServerInstance({ ...config, fetchFn: fetchFn as never }) + ).resolves.toEqual({ kind: 'unreachable' }); + } }); }); diff --git a/apps/web/src/app/server-storage/server-instance-probe.ts b/apps/web/src/app/server-storage/server-instance-probe.ts index ff5e2c236..20e6e9bc6 100644 --- a/apps/web/src/app/server-storage/server-instance-probe.ts +++ b/apps/web/src/app/server-storage/server-instance-probe.ts @@ -3,6 +3,16 @@ import type { IptvnatorRuntimeConfig } from '../services/runtime-config'; const PROBE_TIMEOUT_MS = 2_500; +/** + * - `skipped`: no runtime backend URL, or Electron. + * - `server` / `none`: the backend answered. + * - `unreachable`: no answer, a timeout or a 5xx (nginx answers 502 while + * the backend restarts), so the result says nothing about storage. + */ +export type ServerInstanceProbe = + | { readonly kind: 'skipped' | 'none' | 'unreachable' } + | { readonly kind: 'server'; readonly instance: ServerInstanceInfo }; + export interface ServerInstanceProbeOptions { readonly runtimeConfig?: IptvnatorRuntimeConfig; readonly isElectron?: boolean; @@ -16,36 +26,42 @@ export interface ServerInstanceProbeOptions { */ export async function probeServerInstance( options: ServerInstanceProbeOptions = {} -): Promise { +): Promise { const runtimeConfig = options.runtimeConfig ?? globalThis.window?.__IPTVNATOR_CONFIG__; const isElectron = options.isElectron ?? !!globalThis.window?.electron; const baseUrl = runtimeConfig?.BACKEND_URL?.trim(); if (isElectron || !baseUrl) { - return null; + return { kind: 'skipped' }; } + let response: Response; try { const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis); - const response = await fetchFn( - `${baseUrl.replace(/\/+$/, '')}/v1/instance`, - { - cache: 'no-store', - signal: - typeof AbortSignal.timeout === 'function' - ? AbortSignal.timeout(PROBE_TIMEOUT_MS) - : undefined, - } - ); - if (!response.ok) { - return null; - } + response = await fetchFn(`${baseUrl.replace(/\/+$/, '')}/v1/instance`, { + cache: 'no-store', + signal: + typeof AbortSignal.timeout === 'function' + ? AbortSignal.timeout(PROBE_TIMEOUT_MS) + : undefined, + }); + } catch { + return { kind: 'unreachable' }; + } + if (response.status >= 500) { + return { kind: 'unreachable' }; + } + if (!response.ok) { + // A backend from before server storage has no /v1 routes. + return { kind: 'none' }; + } + try { const body: unknown = await response.json(); return isServerInstance(body) && body.storage === 'server' - ? body - : null; + ? { kind: 'server', instance: body } + : { kind: 'none' }; } catch { - return null; + return { kind: 'none' }; } } diff --git a/apps/web/src/app/server-storage/server-storage-start.spec.ts b/apps/web/src/app/server-storage/server-storage-start.spec.ts new file mode 100644 index 000000000..bcaac3a40 --- /dev/null +++ b/apps/web/src/app/server-storage/server-storage-start.spec.ts @@ -0,0 +1,74 @@ +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import { + REMEMBERED_SERVER_KEY, + resolveServerStorageStart, +} from './server-storage-start'; + +const instance: ServerInstanceInfo = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i-1', + storage: 'server', + auth: 'none', +}; + +describe('resolveServerStorageStart', () => { + afterEach(() => localStorage.clear()); + + it('starts online and remembers a server that answered', () => { + expect( + resolveServerStorageStart({ kind: 'server', instance }, '/api') + ).toEqual({ instance, startedOffline: false }); + expect( + JSON.parse(localStorage.getItem(REMEMBERED_SERVER_KEY) ?? '') + ).toEqual({ baseUrl: '/api', instance }); + }); + + it('starts offline when a remembered server does not answer', () => { + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api') + ).toEqual({ instance, startedOffline: true }); + }); + + it('uses browser mode for an unknown or different server', () => { + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api') + ).toBeNull(); + + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + expect( + resolveServerStorageStart( + { kind: 'unreachable' }, + 'http://other:3000' + ) + ).toBeNull(); + }); + + it('forgets the server once it answers without server storage', () => { + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + + expect(resolveServerStorageStart({ kind: 'none' }, '/api')).toBeNull(); + expect(localStorage.getItem(REMEMBERED_SERVER_KEY)).toBeNull(); + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api') + ).toBeNull(); + }); + + it('ignores a skipped probe and unreadable memory', () => { + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + expect( + resolveServerStorageStart({ kind: 'skipped' }, '/api') + ).toBeNull(); + expect(localStorage.getItem(REMEMBERED_SERVER_KEY)).not.toBeNull(); + + localStorage.setItem(REMEMBERED_SERVER_KEY, '{broken'); + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api') + ).toBeNull(); + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/api', null) + ).toBeNull(); + }); +}); diff --git a/apps/web/src/app/server-storage/server-storage-start.ts b/apps/web/src/app/server-storage/server-storage-start.ts new file mode 100644 index 000000000..4b0e8fdb8 --- /dev/null +++ b/apps/web/src/app/server-storage/server-storage-start.ts @@ -0,0 +1,93 @@ +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import type { ServerInstanceProbe } from './server-instance-probe'; + +/** Last instance this browser saw, per backend URL. */ +export const REMEMBERED_SERVER_KEY = 'iptvnator-server:instance'; + +export interface ServerStorageStart { + readonly instance: ServerInstanceInfo; + /** No answer at start; the instance comes from this browser's memory. */ + readonly startedOffline: boolean; +} + +interface RememberedServer { + readonly baseUrl: string; + readonly instance: ServerInstanceInfo; +} + +/** + * Decides whether the app starts in server mode. A browser that has used + * this server before stays in server mode while it does not answer (shown + * as offline), so it never falls back to an empty browser library and + * collects playlists the server will not see. An answer without server + * storage forgets the server. + */ +export function resolveServerStorageStart( + probe: ServerInstanceProbe, + baseUrl: string, + storage: Pick< + Storage, + 'getItem' | 'setItem' | 'removeItem' + > | null = safeLocalStorage() +): ServerStorageStart | null { + switch (probe.kind) { + case 'server': + write(storage, { baseUrl, instance: probe.instance }); + return { instance: probe.instance, startedOffline: false }; + case 'none': + remove(storage); + return null; + case 'unreachable': { + const remembered = read(storage); + return remembered?.baseUrl === baseUrl + ? { instance: remembered.instance, startedOffline: true } + : null; + } + default: + return null; + } +} + +function read( + storage: Pick | null +): RememberedServer | null { + try { + const raw = storage?.getItem(REMEMBERED_SERVER_KEY); + const parsed = raw ? (JSON.parse(raw) as RememberedServer) : null; + return parsed && + typeof parsed.baseUrl === 'string' && + parsed.instance?.storage === 'server' && + typeof parsed.instance.instanceId === 'string' + ? parsed + : null; + } catch { + return null; + } +} + +function write( + storage: Pick | null, + value: RememberedServer +): void { + try { + storage?.setItem(REMEMBERED_SERVER_KEY, JSON.stringify(value)); + } catch { + // Without storage an offline start falls back to browser mode. + } +} + +function remove(storage: Pick | null): void { + try { + storage?.removeItem(REMEMBERED_SERVER_KEY); + } catch { + // Nothing stored. + } +} + +function safeLocalStorage(): Storage | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} diff --git a/apps/web/src/app/server-storage/server-storage.providers.ts b/apps/web/src/app/server-storage/server-storage.providers.ts index 0021017a9..d70327df8 100644 --- a/apps/web/src/app/server-storage/server-storage.providers.ts +++ b/apps/web/src/app/server-storage/server-storage.providers.ts @@ -11,16 +11,23 @@ import { ServerAccessTokenInterceptor, ServerStorageSession, } from '@iptvnator/services/server-storage'; -import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; import { ServerMigrationPrompt } from './server-migration-prompt.service'; +import type { ServerStorageStart } from './server-storage-start'; /** Loaded with a dynamic import from `main.ts`, only in server mode. */ export function provideServerStorage( - instance: ServerInstanceInfo, + start: ServerStorageStart, baseUrl: string ): (Provider | EnvironmentProviders)[] { return [ - { provide: SERVER_STORAGE_CONFIG, useValue: { instance, baseUrl } }, + { + provide: SERVER_STORAGE_CONFIG, + useValue: { + instance: start.instance, + startedOffline: start.startedOffline, + baseUrl, + }, + }, { provide: USER_DATA_STORAGE, useFactory: () => inject(ServerStorageSession).userData, diff --git a/apps/web/src/app/settings/settings-server-section.component.spec.ts b/apps/web/src/app/settings/settings-server-section.component.spec.ts index 03d38eada..9fdfd8d6a 100644 --- a/apps/web/src/app/settings/settings-server-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-server-section.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { ServerStorageStatusService } from '@iptvnator/services'; import { + SERVER_STORAGE_CONFIG, ServerMigrationService, ServerStorageSession, } from '@iptvnator/services/server-storage'; @@ -37,6 +38,10 @@ describe('SettingsServerSectionComponent', () => { providers: [ { provide: ServerStorageSession, useValue: session }, { provide: ServerMigrationService, useValue: migration }, + { + provide: SERVER_STORAGE_CONFIG, + useValue: { baseUrl: 'http://nas.local:4333/api' }, + }, { provide: ServerStorageStatusService, useValue: { @@ -64,6 +69,9 @@ describe('SettingsServerSectionComponent', () => { const text = fixture.nativeElement.textContent; expect(text).toContain('/data/databases/iptvnator.db'); + expect(byTestId('server-address')?.textContent?.trim()).toBe( + 'http://nas.local:4333' + ); expect(byTestId('server-state')?.dataset['state']).toBe('online'); expect(fixture.componentInstance.sizeLabel()).toBe('38.0 MB'); expect(byTestId('server-sign-out')).toBeNull(); diff --git a/apps/web/src/app/settings/settings-server-section.component.ts b/apps/web/src/app/settings/settings-server-section.component.ts index ef04d58bc..5e2c54173 100644 --- a/apps/web/src/app/settings/settings-server-section.component.ts +++ b/apps/web/src/app/settings/settings-server-section.component.ts @@ -13,6 +13,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; import { ServerStorageStatusService } from '@iptvnator/services'; import { + SERVER_STORAGE_CONFIG, ServerMigrationService, ServerStorageSession, type ServerMigrationResult, @@ -47,7 +48,8 @@ export class SettingsServerSectionComponent { readonly usesToken = computed( () => this.status.instance()?.auth === 'token' ); - readonly address = globalThis.location?.origin ?? ''; + /** The backend's origin; the page's own in Docker (`/api`). */ + readonly address = backendOrigin(inject(SERVER_STORAGE_CONFIG).baseUrl); readonly details = signal(null); readonly browserPlaylistCount = signal(0); readonly importing = signal(false); @@ -99,6 +101,14 @@ export class SettingsServerSectionComponent { } } +function backendOrigin(baseUrl: string): string { + try { + return new URL(baseUrl, globalThis.location?.href).origin; + } catch { + return baseUrl; + } +} + function formatMegabytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 927fb9c43..6ff4dd907 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -5,6 +5,7 @@ import { registerAppDateLocale } from './app/app-date-locales'; import { AppComponent } from './app/app.component'; import { appConfig, getInitialLanguage } from './app/app.config'; import { probeServerInstance } from './app/server-storage/server-instance-probe'; +import { resolveServerStorageStart } from './app/server-storage/server-storage-start'; import { getRuntimeBackendUrl } from './app/services/runtime-config'; // A reloaded packaged renderer arrives on index.html with the route it was @@ -19,16 +20,23 @@ if (restoredHref !== null) { window.history.replaceState(window.history.state, '', restoredHref); } -/** Adds server storage when the self-hosted backend offers it. */ +/** + * Adds server storage when the self-hosted backend offers it, or when this + * browser has used it before and it does not answer right now. + */ async function resolveAppConfig(): Promise { - const instance = await probeServerInstance(); - if (!instance) { + const baseUrl = getRuntimeBackendUrl(); + const start = resolveServerStorageStart( + await probeServerInstance(), + baseUrl + ); + if (!start) { return appConfig; } const { provideServerStorage } = await import('./app/server-storage/server-storage.providers'); return mergeApplicationConfig(appConfig, { - providers: provideServerStorage(instance, getRuntimeBackendUrl()), + providers: provideServerStorage(start, baseUrl), }); } diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 9bf7f4e51..e2482857b 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -170,7 +170,13 @@ expose desktop-only actions in the PWA/shared UI. deployment wrote a runtime `BACKEND_URL` into `window.__IPTVNATOR_CONFIG__` (the Docker entrypoint does; hosted builds and Electron never probe). On `storage: 'server'` it imports `apps/web/src/app/server-storage/` lazily and -merges `provideServerStorage()` into the app config. Everything else of the +merges `provideServerStorage()` into the app config, and remembers the +instance under `iptvnator-server:instance`. When the probe gets no answer +(network error, timeout, or a 5xx such as nginx's 502 while the backend +restarts), a browser that remembers this backend still starts in server +mode, offline, and reloads once the server answers; it never falls back to +an empty browser library. An answer without server storage, or a 404 from +an older backend, forgets it (`server-storage-start.ts`). Everything else of the client lives behind the `@iptvnator/services/server-storage` sub-entry, so other builds never load it; `ServerStorageStatusService` and the token helpers stay in the main `@iptvnator/services` barrel because the rail, diff --git a/docs/superpowers/specs/2026-10-08-server-storage-design.md b/docs/superpowers/specs/2026-10-08-server-storage-design.md index 75f928dd9..4a5619f49 100644 --- a/docs/superpowers/specs/2026-10-08-server-storage-design.md +++ b/docs/superpowers/specs/2026-10-08-server-storage-design.md @@ -170,7 +170,7 @@ https://claude.ai/artifact/3WheJAgeNL5ag6kmtPrJcD | Situation | Behavior | | --- | --- | -| Probe fails or times out (2.5 s) | Browser mode, no server UI. | +| Probe gets no answer (network error, 2.5 s timeout, 5xx) | Server mode, offline, if this browser used this backend before (`iptvnator-server:instance`); the page reloads once the server answers. Otherwise browser mode. An answer without server storage, or a 404, forgets the server. | | Request fails after boot | Status `offline`, banner, write failures show a snackbar; retry on `visibilitychange` and every 30 s. | | `401` | Status `unauthorized`, stored token cleared, redirect to sign-in. | | `413` | Snackbar names the limit. | diff --git a/libs/services/src/lib/server-storage/server-storage-session.spec.ts b/libs/services/src/lib/server-storage/server-storage-session.spec.ts index 955c1ec9a..41fa1e5f4 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.spec.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -32,7 +32,7 @@ describe('ServerStorageSession', () => { TestBed.resetTestingModule(); }); - function setup(auth: 'none' | 'token') { + function setup(auth: 'none' | 'token', startedOffline = false) { TestBed.configureTestingModule({ providers: [ provideRouter([{ path: 'server-sign-in', children: [] }]), @@ -49,6 +49,7 @@ describe('ServerStorageSession', () => { provide: SERVER_STORAGE_CONFIG, useValue: { baseUrl: '/api', + startedOffline, instance: { service: 'iptvnator', apiVersion: 1, @@ -97,4 +98,47 @@ describe('ServerStorageSession', () => { expect(status.state()).toBe('online'); }); + + describe('after an offline start', () => { + it('stays offline until the server answers, then reloads once', async () => { + const { session, status } = setup('none', true); + const reload = jest + .spyOn( + session as unknown as { reloadPage(): void }, + 'reloadPage' + ) + .mockImplementation(() => undefined); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + await session.start(); + expect(status.state()).toBe('offline'); + expect(reload).not.toHaveBeenCalled(); + + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ entries: {} }), + }); + await session.refresh(); + await session.refresh(); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('never reloads after a normal start', async () => { + const { session } = setup('none'); + const reload = jest + .spyOn( + session as unknown as { reloadPage(): void }, + 'reloadPage' + ) + .mockImplementation(() => undefined); + + await session.start(); + await session.refresh(); + + expect(reload).not.toHaveBeenCalled(); + }); + }); }); diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts index 83138d69e..d964c28a2 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -27,6 +27,7 @@ export class ServerStorageSession { private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); private readonly router = inject(Router); + private reloading = false; readonly api = new ServerApiClient({ baseUrl: this.config.baseUrl, @@ -75,6 +76,12 @@ export class ServerStorageSession { await this.userData.hydrate(); } catch { // The API client already reported offline or unauthorized. + return; + } + if (this.config.startedOffline && !this.reloading) { + // Nothing was read at start: boot again against the server. + this.reloading = true; + this.reloadPage(); } } @@ -107,6 +114,11 @@ export class ServerStorageSession { }, RETRY_INTERVAL_MS); } + /** Separate so tests can observe it. */ + protected reloadPage(): void { + window.location.reload(); + } + private announceWriteFailure(error?: ServerApiError): void { const key = error?.status === 413 diff --git a/libs/services/src/lib/server-storage/server-storage.config.ts b/libs/services/src/lib/server-storage/server-storage.config.ts index 624d20bbe..fdeb8ce18 100644 --- a/libs/services/src/lib/server-storage/server-storage.config.ts +++ b/libs/services/src/lib/server-storage/server-storage.config.ts @@ -5,6 +5,11 @@ export interface ServerStorageConfig { readonly instance: ServerInstanceInfo; /** Backend base URL as the PWA resolves it, e.g. `/api`. */ readonly baseUrl: string; + /** + * The start-up probe got no answer and the instance came from this + * browser's memory of the server: the page reloads once it answers. + */ + readonly startedOffline?: boolean; } export const SERVER_STORAGE_CONFIG = new InjectionToken(