fix(server-storage): positions by content, acknowledged keepalive, serial re-reads, token changes

- Moved playback positions are matched by content type and Xtream id and
  name the server playlist.
- Page-hide writes report whether the server took them; refused ones go
  back into the queue. Re-reads run one at a time.
- The access guard's 401 carries WWW-Authenticate: Bearer
  realm="iptvnator"; the interceptor and PwaService's raw fetches turn it
  into sign-in, so a token changed on the server is noticed on provider
  calls too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:29:55 +02:00
1 parent fc32934ce3
commit 6820b87005
17 files changed
+414 -55

No files matched your search

@@ -20,6 +20,7 @@ import {
getStalkerRequestErrorStatus,
isStalkerProbeTimeout,
} from '@iptvnator/portal/stalker/data-access';
import { ServerStorageStatusService } from '@iptvnator/services';
import { PwaService } from './pwa.service';
describe('PwaService', () => {
@@ -279,6 +280,47 @@ describe('PwaService', () => {
globalThis.fetch = originalFetch;
});
it('asks for a new server token when the access guard refuses a Stalker call', async () => {
const status = TestBed.inject(ServerStorageStatusService);
status.activate(
{
service: 'iptvnator',
apiVersion: 1,
instanceId: 'i',
storage: 'server',
auth: 'token',
},
() => undefined
);
const originalFetch = globalThis.fetch;
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers({
'WWW-Authenticate': 'Bearer realm="iptvnator"',
}),
json: async () => ({ code: 'unauthorized' }),
} as unknown as Response) as unknown as typeof fetch;
const outcome = (
service.sendIpcEvent(STALKER_REQUEST, {
url: 'http://portal.example/portal.php',
macAddress: '00:1A:79:AA:BB:CC',
params: { action: 'get_genres' },
silent: true,
}) as Promise<unknown>
).catch((error: unknown) => error);
await Promise.resolve();
http.expectOne((candidate) =>
candidate.url.endsWith('/provider-targets')
).flush({ targetId: 'target-1' });
await outcome;
expect(status.state()).toBe('unauthorized');
globalThis.fetch = originalFetch;
});
it('sends Stalker credentials as /stalker control params, including the serial', async () => {
// JSDOM ships no fetch; install one for the proxy call.
const fetchMock = jest.fn().mockResolvedValue({
+22 -1
View File
@@ -13,7 +13,12 @@ import {
switchMap,
throwError,
} from 'rxjs';
import { DataService, serverAuthorizationHeaders } from '@iptvnator/services';
import {
DataService,
isServerAccessChallenge,
serverAuthorizationHeaders,
ServerStorageStatusService,
} from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
ERROR,
@@ -83,6 +88,7 @@ export class PwaService extends DataService {
private readonly store = inject(Store);
private readonly swUpdate = inject(SwUpdate);
private readonly translateService = inject(TranslateService);
private readonly serverStatus = inject(ServerStorageStatusService);
private readonly logger = createLogger('PwaService');
private readonly providerTargetIds = new Map<string, Promise<string>>();
private readonly silentXtreamActions = new Set<string>([
@@ -207,6 +213,7 @@ export class PwaService extends DataService {
signal: controller.signal,
}
);
this.noteServerAccess(response);
if (!response.ok) {
return { reset: false };
@@ -643,6 +650,7 @@ export class PwaService extends DataService {
const response = await fetch(requestUrl, {
headers: serverAuthorizationHeaders(),
});
this.noteServerAccess(response);
if (!response.ok) {
throw new Error(
@@ -794,4 +802,17 @@ export class PwaService extends DataService {
getAppEnvironment(): string {
return 'pwa';
}
/** A token changed on the server shows up on any backend call. */
private noteServerAccess(response: Response): void {
if (
response.status === 401 &&
isServerAccessChallenge(
response.status,
response.headers.get('WWW-Authenticate')
)
) {
this.serverStatus.reportAccessDenied();
}
}
}