fix(pwa): stay in server mode when a known server does not answer

A failed start-up probe used to fall back to browser mode, which showed an
empty library while the backend restarted and let new playlists land in the
browser. The browser now remembers the server; without an answer it starts
in server mode, offline, and reloads once the server is back. Settings →
Server shows the backend address instead of the page's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:56 +02:00
1 parent 8714b3b445
commit 3bfd5394ef
13 files changed
+357 -69

No files matched your search

@@ -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();
});
});
});
@@ -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
@@ -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<ServerStorageConfig>(