fix(server-storage): retry unsynced user data while online, keep newer typed snapshots, explain a refused token store

- The 30 s retry also re-reads and flushes while online when the first
  user-data read failed or writes the server refused (a 5xx) are queued;
  the same failure notice shows at most every five minutes.
- Moving browser data adds a legacy snapshot alias only where no typed
  snapshot exists, so a newer typed copy is not overwritten.
- writeServerAccessToken reports whether storage kept the token; the
  sign-in page says so (SERVER_STORAGE.SIGN_IN_STORAGE, 19 locales)
  instead of calling a right token rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 21:00:33 +02:00
1 parent 70fabb1424
commit 2d6f28f8c8
30 files changed
+147 -11

No files matched your search

@@ -47,6 +47,9 @@
@case ('unreachable') {
{{ 'SERVER_STORAGE.SIGN_IN_UNREACHABLE' | translate }}
}
@case ('storage') {
{{ 'SERVER_STORAGE.SIGN_IN_STORAGE' | translate }}
}
}
</p>
@@ -82,4 +82,22 @@ describe('ServerSignInComponent', () => {
expect(session.fetchStatus).not.toHaveBeenCalled();
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
});
it('says when the browser will not keep the token', async () => {
const refuse = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError');
});
try {
component.edit('right');
await component.submit(new Event('submit'));
fixture.detectChanges();
} finally {
refuse.mockRestore();
}
expect(session.fetchStatus).not.toHaveBeenCalled();
expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_STORAGE');
});
});
@@ -19,7 +19,7 @@ import {
} from '@iptvnator/services/server-storage';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
export type ServerSignInFeedback = 'rejected' | 'unreachable';
export type ServerSignInFeedback = 'rejected' | 'unreachable' | 'storage';
@Component({
selector: 'app-server-sign-in',
@@ -53,9 +53,13 @@ export class ServerSignInComponent {
if (!token || this.busy()) {
return;
}
// Every request and the reload read the token from storage.
if (!writeServerAccessToken(token)) {
this.feedback.set('storage');
return;
}
this.busy.set(true);
this.feedback.set(null);
writeServerAccessToken(token);
try {
await this.session.fetchStatus();
this.openWorkspace();
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "تجده في إعداد IPTVNATOR_ACCESS_TOKEN على الخادم.",
"SIGN_IN_REJECTED": "تم رفض الرمز. تحقق منه وحاول مرة أخرى.",
"SIGN_IN_UNREACHABLE": "تعذّر الوصول إلى الخادم. تحقق من الاتصال وحاول مرة أخرى.",
"SIGN_IN_STORAGE": "لا يسمح هذا المتصفح لـ IPTVnator بحفظ الرمز. اسمح ببيانات الموقع لهذه الصفحة وحاول مرة أخرى.",
"SIGN_IN_CONTINUE": "متابعة",
"STATUS_ONLINE": "الخادم متصل",
"STATUS_OFFLINE": "الخادم غير متاح",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "تلقاه فالإعداد IPTVNATOR_ACCESS_TOKEN ديال السيرفر.",
"SIGN_IN_REJECTED": "الرمز ترفض. تأكد منو وعاود جرب.",
"SIGN_IN_UNREACHABLE": "ما قدرناش نوصلو للسيرفر. شوف الاتصال وعاود جرب.",
"SIGN_IN_STORAGE": "هاد المتصفح ما كيخليش IPTVnator يحفظ الرمز. سمح بداتا ديال الموقع لهاد الصفحة وعاود جرب.",
"SIGN_IN_CONTINUE": "كمّل",
"STATUS_ONLINE": "السيرفر متاصل",
"STATUS_OFFLINE": "السيرفر ماشي متوفر",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Ён зададзены ў наладзе IPTVNATOR_ACCESS_TOKEN на серверы.",
"SIGN_IN_REJECTED": "Токен адхілены. Праверце яго і паспрабуйце зноў.",
"SIGN_IN_UNREACHABLE": "Сервер недаступны. Праверце злучэнне і паспрабуйце зноў.",
"SIGN_IN_STORAGE": "Браўзер не дазваляе IPTVnator захаваць токен. Дазвольце даныя сайта для гэтай старонкі і паспрабуйце зноў.",
"SIGN_IN_CONTINUE": "Працягнуць",
"STATUS_ONLINE": "Сервер падключаны",
"STATUS_OFFLINE": "Сервер недаступны",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Es steht in der Einstellung IPTVNATOR_ACCESS_TOKEN des Servers.",
"SIGN_IN_REJECTED": "Token abgelehnt. Prüfen Sie es und versuchen Sie es erneut.",
"SIGN_IN_UNREACHABLE": "Server nicht erreichbar. Prüfen Sie die Verbindung und versuchen Sie es erneut.",
"SIGN_IN_STORAGE": "Dieser Browser lässt IPTVnator das Token nicht speichern. Erlauben Sie Websitedaten für diese Seite und versuchen Sie es erneut.",
"SIGN_IN_CONTINUE": "Weiter",
"STATUS_ONLINE": "Server verbunden",
"STATUS_OFFLINE": "Server nicht erreichbar",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Θα το βρείτε στη ρύθμιση IPTVNATOR_ACCESS_TOKEN του διακομιστή.",
"SIGN_IN_REJECTED": "Το διακριτικό απορρίφθηκε. Ελέγξτε το και δοκιμάστε ξανά.",
"SIGN_IN_UNREACHABLE": "Ο διακομιστής δεν είναι προσβάσιμος. Ελέγξτε τη σύνδεση και δοκιμάστε ξανά.",
"SIGN_IN_STORAGE": "Αυτό το πρόγραμμα περιήγησης δεν επιτρέπει στο IPTVnator να αποθηκεύσει το διακριτικό. Επιτρέψτε τα δεδομένα ιστότοπου για αυτή τη σελίδα και δοκιμάστε ξανά.",
"SIGN_IN_CONTINUE": "Συνέχεια",
"STATUS_ONLINE": "Ο διακομιστής είναι συνδεδεμένος",
"STATUS_OFFLINE": "Ο διακομιστής δεν είναι διαθέσιμος",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Find it in the server's IPTVNATOR_ACCESS_TOKEN setting.",
"SIGN_IN_REJECTED": "Token rejected. Check it and try again.",
"SIGN_IN_UNREACHABLE": "Cannot reach the server. Check the connection and try again.",
"SIGN_IN_STORAGE": "This browser does not let IPTVnator keep the token. Allow site data for this page and try again.",
"SIGN_IN_CONTINUE": "Continue",
"STATUS_ONLINE": "Server connected",
"STATUS_OFFLINE": "Server unavailable",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Lo encontrará en el ajuste IPTVNATOR_ACCESS_TOKEN del servidor.",
"SIGN_IN_REJECTED": "Token rechazado. Compruébelo e inténtelo de nuevo.",
"SIGN_IN_UNREACHABLE": "No se puede contactar con el servidor. Compruebe la conexión e inténtelo de nuevo.",
"SIGN_IN_STORAGE": "Este navegador no permite que IPTVnator guarde el token. Permita los datos del sitio para esta página e inténtelo de nuevo.",
"SIGN_IN_CONTINUE": "Continuar",
"STATUS_ONLINE": "Servidor conectado",
"STATUS_OFFLINE": "Servidor no disponible",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Il figure dans le paramètre IPTVNATOR_ACCESS_TOKEN du serveur.",
"SIGN_IN_REJECTED": "Jeton refusé. Vérifiez-le et réessayez.",
"SIGN_IN_UNREACHABLE": "Serveur injoignable. Vérifiez la connexion et réessayez.",
"SIGN_IN_STORAGE": "Ce navigateur ne permet pas à IPTVnator d’enregistrer le jeton. Autorisez les données de site pour cette page et réessayez.",
"SIGN_IN_CONTINUE": "Continuer",
"STATUS_ONLINE": "Serveur connecté",
"STATUS_OFFLINE": "Serveur indisponible",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "A szerver IPTVNATOR_ACCESS_TOKEN beállításában találja.",
"SIGN_IN_REJECTED": "A tokent elutasította a szerver. Ellenőrizze, és próbálja újra.",
"SIGN_IN_UNREACHABLE": "A szerver nem érhető el. Ellenőrizze a kapcsolatot, és próbálja újra.",
"SIGN_IN_STORAGE": "Ez a böngésző nem engedi, hogy az IPTVnator megőrizze a tokent. Engedélyezze a webhelyadatokat ehhez az oldalhoz, és próbálja újra.",
"SIGN_IN_CONTINUE": "Tovább",
"STATUS_ONLINE": "Szerver csatlakoztatva",
"STATUS_OFFLINE": "A szerver nem érhető el",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Lo trovi nell’impostazione IPTVNATOR_ACCESS_TOKEN del server.",
"SIGN_IN_REJECTED": "Token rifiutato. Controllalo e riprova.",
"SIGN_IN_UNREACHABLE": "Impossibile raggiungere il server. Controlla la connessione e riprova.",
"SIGN_IN_STORAGE": "Questo browser non consente a IPTVnator di salvare il token. Consenti i dati del sito per questa pagina e riprova.",
"SIGN_IN_CONTINUE": "Continua",
"STATUS_ONLINE": "Server connesso",
"STATUS_OFFLINE": "Server non disponibile",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "サーバーの IPTVNATOR_ACCESS_TOKEN 設定で確認できます。",
"SIGN_IN_REJECTED": "トークンが拒否されました。確認して、もう一度お試しください。",
"SIGN_IN_UNREACHABLE": "サーバーに接続できません。接続を確認して、もう一度お試しください。",
"SIGN_IN_STORAGE": "このブラウザーでは IPTVnator がトークンを保存できません。このページのサイトデータを許可して、もう一度お試しください。",
"SIGN_IN_CONTINUE": "続行",
"STATUS_ONLINE": "サーバーに接続済み",
"STATUS_OFFLINE": "サーバーを利用できません",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "서버의 IPTVNATOR_ACCESS_TOKEN 설정에서 확인할 수 있습니다.",
"SIGN_IN_REJECTED": "토큰이 거부되었습니다. 확인하고 다시 시도하세요.",
"SIGN_IN_UNREACHABLE": "서버에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.",
"SIGN_IN_STORAGE": "이 브라우저에서는 IPTVnator가 토큰을 저장할 수 없습니다. 이 페이지의 사이트 데이터를 허용하고 다시 시도하세요.",
"SIGN_IN_CONTINUE": "계속",
"STATUS_ONLINE": "서버 연결됨",
"STATUS_OFFLINE": "서버를 사용할 수 없음",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Je vindt het in de instelling IPTVNATOR_ACCESS_TOKEN van de server.",
"SIGN_IN_REJECTED": "Token geweigerd. Controleer het en probeer het opnieuw.",
"SIGN_IN_UNREACHABLE": "De server is niet bereikbaar. Controleer de verbinding en probeer het opnieuw.",
"SIGN_IN_STORAGE": "Deze browser laat IPTVnator het token niet bewaren. Sta sitegegevens voor deze pagina toe en probeer het opnieuw.",
"SIGN_IN_CONTINUE": "Doorgaan",
"STATUS_ONLINE": "Server verbonden",
"STATUS_OFFLINE": "Server niet bereikbaar",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Znajdziesz go w ustawieniu IPTVNATOR_ACCESS_TOKEN serwera.",
"SIGN_IN_REJECTED": "Token odrzucony. Sprawdź go i spróbuj ponownie.",
"SIGN_IN_UNREACHABLE": "Brak połączenia z serwerem. Sprawdź połączenie i spróbuj ponownie.",
"SIGN_IN_STORAGE": "Ta przeglądarka nie pozwala IPTVnator zapisać tokenu. Zezwól na dane witryny dla tej strony i spróbuj ponownie.",
"SIGN_IN_CONTINUE": "Dalej",
"STATUS_ONLINE": "Serwer połączony",
"STATUS_OFFLINE": "Serwer niedostępny",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Ele está na configuração IPTVNATOR_ACCESS_TOKEN do servidor.",
"SIGN_IN_REJECTED": "Token recusado. Verifique e tente novamente.",
"SIGN_IN_UNREACHABLE": "Não foi possível acessar o servidor. Verifique a conexão e tente novamente.",
"SIGN_IN_STORAGE": "Este navegador não permite que o IPTVnator salve o token. Permita os dados do site para esta página e tente novamente.",
"SIGN_IN_CONTINUE": "Continuar",
"STATUS_ONLINE": "Servidor conectado",
"STATUS_OFFLINE": "Servidor indisponível",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Он задан в настройке IPTVNATOR_ACCESS_TOKEN на сервере.",
"SIGN_IN_REJECTED": "Токен отклонён. Проверьте его и попробуйте снова.",
"SIGN_IN_UNREACHABLE": "Сервер недоступен. Проверьте подключение и попробуйте снова.",
"SIGN_IN_STORAGE": "Браузер не даёт IPTVnator сохранить токен. Разрешите данные сайта для этой страницы и попробуйте снова.",
"SIGN_IN_CONTINUE": "Продолжить",
"STATUS_ONLINE": "Сервер подключён",
"STATUS_OFFLINE": "Сервер недоступен",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "Sunucunun IPTVNATOR_ACCESS_TOKEN ayarında bulabilirsiniz.",
"SIGN_IN_REJECTED": "Belirteç reddedildi. Kontrol edip tekrar deneyin.",
"SIGN_IN_UNREACHABLE": "Sunucuya ulaşılamıyor. Bağlantıyı kontrol edip tekrar deneyin.",
"SIGN_IN_STORAGE": "Bu tarayıcı IPTVnator'ın belirteci saklamasına izin vermiyor. Bu sayfa için site verilerine izin verip tekrar deneyin.",
"SIGN_IN_CONTINUE": "Devam",
"STATUS_ONLINE": "Sunucu bağlı",
"STATUS_OFFLINE": "Sunucu kullanılamıyor",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "可在服务器的 IPTVNATOR_ACCESS_TOKEN 设置中找到。",
"SIGN_IN_REJECTED": "令牌被拒绝。请检查后重试。",
"SIGN_IN_UNREACHABLE": "无法连接到服务器。请检查连接后重试。",
"SIGN_IN_STORAGE": "此浏览器不允许 IPTVnator 保存令牌。请允许此页面的网站数据后重试。",
"SIGN_IN_CONTINUE": "继续",
"STATUS_ONLINE": "服务器已连接",
"STATUS_OFFLINE": "服务器不可用",
+1
View File
@@ -673,6 +673,7 @@
"SIGN_IN_HINT": "可在伺服器的 IPTVNATOR_ACCESS_TOKEN 設定中找到。",
"SIGN_IN_REJECTED": "權杖遭拒。請檢查後再試一次。",
"SIGN_IN_UNREACHABLE": "無法連線到伺服器。請檢查連線後再試一次。",
"SIGN_IN_STORAGE": "此瀏覽器不允許 IPTVnator 儲存權杖。請允許此頁面的網站資料後再試一次。",
"SIGN_IN_CONTINUE": "繼續",
"STATUS_ONLINE": "伺服器已連線",
"STATUS_OFFLINE": "伺服器無法使用",
+5 -3
View File
@@ -253,18 +253,20 @@ settings and guards read them in every build.
up to 3 s for the first read.
- **Connection state.** `ServerStorageStatusService` is `online`, `offline`
(a request did not reach the server, or a proxy answered 502, 503 or 504
for it; retried every 30 s and when the tab returns) or
for it; retried every 30 s and when the tab returns, as are writes the
server refused and a first read that failed while it is `online`) or
`unauthorized` (a 401 clears the stored token; only a reload after a new
token leaves it, so a late answer cannot undo sign-out). The rail shows a status
shortcut, an offline banner sits above the workspace content, and failed
writes raise a snackbar (`SERVER_STORAGE.WRITE_FAILED`, or `TOO_LARGE` for
a 413).
a 413), the same one at most every five minutes.
- **Access token.** With `IPTVNATOR_ACCESS_TOKEN` the instance reports
`auth: 'token'` (the backend refuses a token without server storage: the
PWA has no sign-in flow for a stateless backend); without a stored token, or after a 401, the guards route to
`/server-sign-in` (the start-up screen lets it through although the
playlists could not load), which checks the token against `/v1/instance/status`
before it stores it under `iptvnator-server:access-token` and reloads.
before it stores it under `iptvnator-server:access-token` and reloads (a
browser that refuses to store it is told so instead).
`ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the
backend, and `PwaService` adds it to its two raw `fetch` calls. Both turn
the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`,
@@ -27,4 +27,19 @@ describe('server access token', () => {
clearServerAccessToken();
expect(readServerAccessToken()).toBeNull();
});
it('reports a token browser storage refuses', () => {
const refuse = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError');
});
try {
expect(writeServerAccessToken('s3cret')).toBe(false);
expect(readServerAccessToken()).toBeNull();
} finally {
refuse.mockRestore();
}
expect(writeServerAccessToken('s3cret')).toBe(true);
});
});
+4 -2
View File
@@ -21,11 +21,13 @@ export function readServerAccessToken(): string | null {
}
}
export function writeServerAccessToken(token: string): void {
/** False when browser storage refused it (private mode, full). */
export function writeServerAccessToken(token: string): boolean {
try {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, token.trim());
return true;
} catch {
// Storage unavailable (private mode): the token cannot persist.
return false;
}
}
@@ -66,4 +66,19 @@ describe('typeLegacyXtreamRefs', () => {
})['xtream-favorites']
).toEqual(['movie:42']);
});
it('keeps a typed snapshot newer than its legacy alias', () => {
const typed = typeLegacyXtreamRefs({
'xtream-favorites': ['movie:42'],
'xtream-collection-items': {
'42': { xtream_id: 42, type: 'movie', backdrop: 'old' },
'movie:42': { xtream_id: 42, type: 'movie', backdrop: 'new' },
},
});
expect(typed['xtream-collection-items']).toEqual({
'42': { xtream_id: 42, type: 'movie', backdrop: 'old' },
'movie:42': { xtream_id: 42, type: 'movie', backdrop: 'new' },
});
});
});
@@ -48,11 +48,12 @@ export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData {
if (isRecord(data[SNAPSHOTS])) {
// The bare key stays as an alias: a reference not yet rewritten (a
// failed write, an older client) still finds its snapshot, and a
// retry can type it again.
// retry can type it again. A typed snapshot that exists is newer.
const aliased: Record<string, unknown> = { ...snapshots };
for (const [key, snapshot] of Object.entries(snapshots)) {
if (/^\d+$/.test(key)) {
aliased[String(typed(Number(key)))] = snapshot;
const typedKey = String(typed(Number(key)));
if (/^\d+$/.test(key) && !(typedKey in snapshots)) {
aliased[typedKey] = snapshot;
}
}
result[SNAPSHOTS] = aliased;
@@ -100,6 +100,41 @@ describe('ServerStorageSession', () => {
expect(status.state()).toBe('online');
});
it('retries a write the server refused while it stays online', async () => {
const { session, status } = setup('none');
await session.start();
const ok = fetchMock.getMockImplementation();
fetchMock.mockImplementation(
async (url: string, init?: { method?: string }) =>
init?.method === 'PUT'
? {
status: 500,
ok: false,
headers: new Headers(),
json: async () => ({ code: 'x', message: 'down' }),
}
: ok?.(url, init)
);
session.userData.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['x'] })
);
await session.userData.flush();
expect(status.state()).toBe('online');
expect(session.userData.hasUnsentWrites()).toBe(true);
fetchMock.mockImplementation(async () => ({
status: 200,
ok: true,
headers: new Headers(),
json: async () => ({ entries: {}, tag: 't' }),
}));
await jest.advanceTimersByTimeAsync(30_000);
expect(session.userData.hasUnsentWrites()).toBe(false);
});
describe('after an offline start', () => {
it('stays offline until the server answers, then reloads once', async () => {
const { session, status } = setup('none', true);
@@ -26,6 +26,7 @@ import { ServerUserDataStorage } from './server-user-data-storage';
const RETRY_INTERVAL_MS = 30_000;
const STARTUP_HYDRATE_WAIT_MS = 3_000;
const NOTICE_REPEAT_MS = 5 * 60_000;
/** Server mode for this page: created only when the probe found server storage. */
@Injectable({ providedIn: 'root' })
@@ -37,6 +38,7 @@ export class ServerStorageSession {
private readonly translate = inject(TranslateService);
private readonly router = inject(Router);
private reloading = false;
private lastNotice = { key: '', at: 0 };
readonly api = new ServerApiClient({
baseUrl: this.config.baseUrl,
@@ -140,7 +142,13 @@ export class ServerStorageSession {
);
setInterval(() => {
this.userData.touch();
if (this.status.state() === 'offline') {
// Also online: a write the server refused (a 5xx) or a first
// read that failed is not retried by anything else.
const state = this.status.state();
if (
state === 'offline' ||
(state === 'online' && this.userData.needsSync())
) {
void this.refresh();
}
}, RETRY_INTERVAL_MS);
@@ -156,6 +164,15 @@ export class ServerStorageSession {
error?.status === 413
? 'SERVER_STORAGE.TOO_LARGE'
: 'SERVER_STORAGE.WRITE_FAILED';
// Retries must not repeat the same notice every 30 s.
const now = Date.now();
if (
key === this.lastNotice.key &&
now - this.lastNotice.at < NOTICE_REPEAT_MS
) {
return;
}
this.lastNotice = { key, at: now };
this.snackBar.open(this.translate.instant(key), undefined, {
duration: 5000,
});
@@ -178,6 +178,11 @@ export class ServerUserDataStorage implements UserDataStorage {
}
}
/** Not read yet, or holding writes the server has not taken. */
needsSync(): boolean {
return !this.hydrated || this.hasUnsentWrites();
}
/** Whether a write is queued, being retried, or still travelling. */
hasUnsentWrites(): boolean {
return this.writes.hasUnsent() || this.deferred.size > 0;