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

@@ -1,5 +1,15 @@
import { SERVER_ACCESS_CHALLENGE } from '@iptvnator/shared/interfaces';
export const SERVER_ACCESS_TOKEN_STORAGE_KEY = 'iptvnator-server:access-token';
/** A 401 from the backend's access guard, not one a provider sent. */
export function isServerAccessChallenge(
status: number,
challenge: string | null
): boolean {
return status === 401 && challenge === SERVER_ACCESS_CHALLENGE;
}
export function readServerAccessToken(): string | null {
try {
const token = localStorage
@@ -1,5 +1,6 @@
import { computed, Injectable, signal } from '@angular/core';
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
import { clearServerAccessToken } from './server-access-token';
export type ServerConnectionState =
'inactive' | 'online' | 'offline' | 'unauthorized';
@@ -49,6 +50,17 @@ export class ServerStorageStatusService {
}
}
/**
* The access guard refused a request outside the storage API (a
* provider proxy call): the stored token is stale, ask for a new one.
*/
reportAccessDenied(): void {
if (this.isActive()) {
clearServerAccessToken();
this.stateSignal.set('unauthorized');
}
}
retry(): void {
this.retryHandler?.();
}
@@ -10,6 +10,7 @@ import {
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token';
import { ServerStorageStatusService } from '../server-storage-status.service';
import { ServerAccessTokenInterceptor } from './server-access-token.interceptor';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
@@ -53,4 +54,41 @@ describe('ServerAccessTokenInterceptor', () => {
.request.headers.has('Authorization')
).toBe(false);
});
it('asks for a new token when the backend refuses the stored one', () => {
const status = TestBed.inject(ServerStorageStatusService);
status.activate(
{
service: 'iptvnator',
apiVersion: 1,
instanceId: 'i',
storage: 'server',
auth: 'token',
},
() => undefined
);
const http = TestBed.inject(HttpClient);
const controller = TestBed.inject(HttpTestingController);
// A provider's own 401 passed on by the proxy changes nothing.
http.get('/api/xtream').subscribe({ error: () => undefined });
controller
.expectOne('/api/xtream')
.flush({ message: 'bad login' }, { status: 401, statusText: 'x' });
expect(status.state()).toBe('online');
http.get('/api/xtream').subscribe({ error: () => undefined });
controller.expectOne('/api/xtream').flush(
{ code: 'unauthorized' },
{
status: 401,
statusText: 'Unauthorized',
headers: { 'WWW-Authenticate': 'Bearer realm="iptvnator"' },
}
);
expect(status.state()).toBe('unauthorized');
expect(
localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
).toBeNull();
});
});
@@ -1,17 +1,27 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { readServerAccessToken } from '../server-access-token';
import { Observable, tap } from 'rxjs';
import {
isServerAccessChallenge,
readServerAccessToken,
} from '../server-access-token';
import { ServerStorageStatusService } from '../server-storage-status.service';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
/** Adds the server access token to `HttpClient` calls to the backend. */
/**
* Adds the server access token to `HttpClient` calls to the backend, and
* sends the user to sign-in when the backend refuses it (a token changed
* on the server while this page was open).
*/
@Injectable()
export class ServerAccessTokenInterceptor implements HttpInterceptor {
private readonly status = inject(ServerStorageStatusService);
private readonly baseUrl = inject(SERVER_STORAGE_CONFIG).baseUrl.replace(
/\/+$/,
''
@@ -21,12 +31,29 @@ export class ServerAccessTokenInterceptor implements HttpInterceptor {
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
const token = readServerAccessToken();
if (!token || !request.url.startsWith(this.baseUrl)) {
if (!request.url.startsWith(this.baseUrl)) {
return next.handle(request);
}
return next.handle(
request.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
const token = readServerAccessToken();
const sent = token
? request.clone({
setHeaders: { Authorization: `Bearer ${token}` },
})
: request;
return next.handle(sent).pipe(
tap({
error: (error: unknown) => {
if (
error instanceof HttpErrorResponse &&
isServerAccessChallenge(
error.status,
error.headers.get('WWW-Authenticate')
)
) {
this.status.reportAccessDenied();
}
},
})
);
}
}
@@ -84,6 +84,21 @@ describe('ServerApiClient', () => {
expect(status.reportOffline).not.toHaveBeenCalled();
});
it('reports whether a keepalive write was taken', async () => {
const ok = jest.fn().mockResolvedValue(response(200, {}));
await expect(
client(ok).sendKeepalive('PUT', '/user-state/a/b', { value: 1 })
).resolves.toBe(true);
expect(ok.mock.calls[0][1]).toMatchObject({ keepalive: true });
const refused = jest
.fn()
.mockRejectedValue(new TypeError('body too large'));
await expect(
client(refused).sendKeepalive('DELETE', '/user-state/a/b')
).resolves.toBe(false);
});
it('clears the token and reports unauthorized on 401', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'old');
const fetchFn = jest
@@ -123,22 +123,31 @@ export class ServerApiClient {
}
/** Best effort while the page is hidden or closed; errors are ignored. */
sendKeepalive(
/**
* A write that survives the page. Resolves whether the server took it;
* browsers reject keepalive bodies above 64 KiB, which resolves false.
*/
async sendKeepalive(
method: 'PUT' | 'DELETE',
path: string,
body?: unknown
): void {
void this.fetchFn()(this.url(path), {
method,
keepalive: true,
headers: {
...serverAuthorizationHeaders(),
...(body === undefined
? {}
: { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
}).catch(() => undefined);
): Promise<boolean> {
try {
const response = await this.fetchFn()(this.url(path), {
method,
keepalive: true,
headers: {
...serverAuthorizationHeaders(),
...(body === undefined
? {}
: { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return response.ok;
} catch {
return false;
}
}
private fetchFn(): typeof fetch {
@@ -1,4 +1,8 @@
import { combineUserData, unionItems } from './server-migration-merge';
import {
combineUserData,
retargetUserData,
unionItems,
} from './server-migration-merge';
describe('combineUserData', () => {
it('unions lists, server entries first', () => {
@@ -40,3 +44,46 @@ describe('unionItems', () => {
]);
});
});
describe('playback positions', () => {
it('keep the server position of the same item', () => {
expect(
combineUserData([
[
{
contentType: 'vod',
contentXtreamId: 7,
positionSeconds: 60,
},
],
[
{
contentType: 'vod',
contentXtreamId: 7,
positionSeconds: 900,
},
{
contentType: 'episode',
contentXtreamId: 7,
positionSeconds: 5,
},
],
])
).toEqual([
{ contentType: 'vod', contentXtreamId: 7, positionSeconds: 60 },
{ contentType: 'episode', contentXtreamId: 7, positionSeconds: 5 },
]);
});
it('name the server playlist after a move', () => {
expect(
retargetUserData(
[{ contentXtreamId: 1, playlistId: 'b' }, 'live:1'],
'b',
's'
)
).toEqual([{ contentXtreamId: 1, playlistId: 's' }, 'live:1']);
const same = [{ playlistId: 'b' }];
expect(retargetUserData(same, 'b', 'b')).toBe(same);
});
});
@@ -41,11 +41,21 @@ export function unionItems<T>(...lists: readonly (readonly T[])[]): T[] {
return result;
}
/** Content ids or URLs identify favorites and history entries. */
/**
* Content ids or URLs identify favorites and history entries; a playback
* position is identified by its content type and Xtream id.
*/
function itemKey(item: unknown): string {
if (!isPlainObject(item)) {
return `value:${JSON.stringify(item)}`;
}
if (
typeof item['contentType'] === 'string' &&
(typeof item['contentXtreamId'] === 'number' ||
typeof item['contentXtreamId'] === 'string')
) {
return `position:${item['contentType']}:${item['contentXtreamId']}`;
}
for (const field of ['id', 'stream_id', 'series_id', 'movie_id', 'url']) {
const value = item[field];
if (typeof value === 'string' || typeof value === 'number') {
@@ -55,6 +65,25 @@ function itemKey(item: unknown): string {
return `json:${JSON.stringify(item)}`;
}
/**
* A browser value moved under another playlist id: list items that name
* their playlist (playback positions do) name the server's instead.
*/
export function retargetUserData(
value: unknown,
browserId: string,
serverId: string
): unknown {
if (browserId === serverId || !Array.isArray(value)) {
return value;
}
return value.map((item) =>
isPlainObject(item) && item['playlistId'] === browserId
? { ...item, playlistId: serverId }
: item
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
@@ -7,7 +7,11 @@ import {
type Playlist,
type ServerUserStateResponse,
} from '@iptvnator/shared/interfaces';
import { combineUserData, unionItems } from './server-migration-merge';
import {
combineUserData,
retargetUserData,
unionItems,
} from './server-migration-merge';
import { planServerMigration } from './server-migration-plan';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
import { ServerStorageSession } from './server-storage-session';
@@ -180,7 +184,7 @@ export class ServerMigrationService {
if (serverId) {
incoming.set(serverId, [
...(incoming.get(serverId) ?? []),
value,
retargetUserData(value, browserId, serverId),
]);
}
}
@@ -34,7 +34,7 @@ describe('ServerUserDataStorage', () => {
}),
sendJson: jest.fn().mockResolvedValue(undefined),
request: jest.fn().mockResolvedValue(undefined),
sendKeepalive: jest.fn(),
sendKeepalive: jest.fn().mockResolvedValue(true),
};
const onWriteFailure = jest.fn();
const storage = new ServerUserDataStorage(
@@ -259,4 +259,44 @@ describe('ServerUserDataStorage', () => {
{ value: ['b'] }
);
});
it('queues a page-hide write again when the server did not take it', async () => {
const { api, storage } = setup();
await storage.hydrate();
api.sendKeepalive.mockResolvedValueOnce(false);
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['big'] }));
storage.flushWithKeepalive();
await settle();
// Back on the page: the re-read sends it normally and keeps it.
api.getJson.mockResolvedValueOnce({
entries: { 'xtream-favorites': { p1: ['big'] } },
});
await storage.hydrate();
expect(api.sendJson).toHaveBeenCalledWith(
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['big'] }
);
});
it('runs re-reads one after another', async () => {
const { api, storage } = setup();
await storage.hydrate();
let answerFirst: (value: unknown) => void = () => undefined;
api.getJson.mockReturnValueOnce(
new Promise((resolve) => (answerFirst = resolve))
);
const first = storage.hydrate();
const second = storage.hydrate();
await settle();
expect(api.getJson).toHaveBeenCalledTimes(2);
answerFirst({ entries: {} });
await first;
await second;
expect(api.getJson).toHaveBeenCalledTimes(3);
});
});
@@ -39,6 +39,10 @@ export class ServerUserDataStorage implements UserDataStorage {
/** Batches go out one at a time, so an older write never lands last. */
private sending: Promise<void> = Promise.resolve();
private batchesInFlight = 0;
/** Page-hide writes still travelling, by `namespace|playlistId`. */
private readonly keepaliveKeys = new Set<string>();
/** Re-reads run one at a time, so an older one never lands last. */
private hydration: Promise<void> = Promise.resolve();
/** Edit counter per `namespace|playlistId`, for hydrate to keep them. */
private readonly edits = new Map<string, number>();
private editCount = 0;
@@ -78,7 +82,16 @@ export class ServerUserDataStorage implements UserDataStorage {
* writes. Entries edited while the read was in flight, and entries whose
* write failed, keep their local value: the server does not have it yet.
*/
async hydrate(): Promise<void> {
hydrate(): Promise<void> {
const run = this.hydration.then(() => this.hydrateNow());
this.hydration = run.then(
() => undefined,
() => undefined
);
return run;
}
private async hydrateNow(): Promise<void> {
// Edits from here on may miss the read: queued, or still travelling.
const editsBefore = this.editCount;
await this.flush();
@@ -91,7 +104,10 @@ export class ServerUserDataStorage implements UserDataStorage {
const current = parseMap(
this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}'
);
const unsaved = new Set(this.pending.keys());
const unsaved = new Set([
...this.pending.keys(),
...this.keepaliveKeys,
]);
for (const [key, edit] of this.edits) {
if (edit > editsBefore) {
unsaved.add(key);
@@ -115,7 +131,11 @@ export class ServerUserDataStorage implements UserDataStorage {
);
}
for (const [key, edit] of this.edits) {
if (edit <= editsBefore) {
if (
edit <= editsBefore &&
!this.pending.has(key) &&
!this.keepaliveKeys.has(key)
) {
this.edits.delete(key);
}
}
@@ -153,14 +173,22 @@ export class ServerUserDataStorage implements UserDataStorage {
}
const sendAll = () => {
for (const entry of entries) {
const key = entryKey(entry);
const edit = this.edits.get(key);
this.keepaliveKeys.add(key);
const path = entryPath(entry);
if (entry.value === undefined) {
this.api.sendKeepalive('DELETE', path);
} else {
this.api.sendKeepalive('PUT', path, {
value: entry.value,
});
}
const sent =
entry.value === undefined
? this.api.sendKeepalive('DELETE', path)
: this.api.sendKeepalive('PUT', path, {
value: entry.value,
});
void sent.then((ok) => {
this.keepaliveKeys.delete(key);
if (!ok) {
this.requeue(entry, edit);
}
});
}
};
if (this.batchesInFlight === 0) {
@@ -196,7 +224,7 @@ export class ServerUserDataStorage implements UserDataStorage {
}
private queue(entry: PendingEntry): void {
const key = `${entry.namespace}|${entry.playlistId}`;
const key = entryKey(entry);
this.pending.set(key, entry);
this.edits.set(key, ++this.editCount);
}
@@ -221,7 +249,22 @@ export class ServerUserDataStorage implements UserDataStorage {
return batch;
}
/**
* Back into the queue after a failed write, for the offline retry or
* the next edit, unless the entry was edited since it was sent.
*/
private requeue(
entry: PendingEntry,
editWhenSent: number | undefined
): void {
const key = entryKey(entry);
if (this.edits.get(key) === editWhenSent && !this.pending.has(key)) {
this.pending.set(key, entry);
}
}
private async send(entry: PendingEntry): Promise<void> {
const edit = this.edits.get(entryKey(entry));
try {
if (entry.value === undefined) {
await this.api.request(entryPath(entry), { method: 'DELETE' });
@@ -231,17 +274,16 @@ export class ServerUserDataStorage implements UserDataStorage {
});
}
} catch (error) {
// Keep it for the next flush (the offline retry or the next
// edit) unless a newer value for the entry is already queued.
const key = `${entry.namespace}|${entry.playlistId}`;
if (!this.pending.has(key)) {
this.pending.set(key, entry);
}
this.requeue(entry, edit);
this.onWriteFailure(error);
}
}
}
function entryKey(entry: PendingEntry): string {
return `${entry.namespace}|${entry.playlistId}`;
}
function splitEditKey(key: string): [string, string] {
const separator = key.indexOf('|');
return [key.slice(0, separator), key.slice(separator + 1)];