From 2d6f28f8c8b1aa4211ec31e1af434859916155b1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 21:00:33 +0200 Subject: [PATCH] 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 --- .../server-sign-in.component.html | 3 ++ .../server-sign-in.component.spec.ts | 18 ++++++++++ .../server-sign-in.component.ts | 8 +++-- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/hu.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/pwa-self-hosted.md | 8 +++-- .../src/lib/server-access-token.spec.ts | 15 ++++++++ libs/services/src/lib/server-access-token.ts | 6 ++-- .../server-migration-xtream.spec.ts | 15 ++++++++ .../server-storage/server-migration-xtream.ts | 7 ++-- .../server-storage-session.spec.ts | 35 +++++++++++++++++++ .../server-storage/server-storage-session.ts | 19 +++++++++- .../server-user-data-storage.ts | 5 +++ 30 files changed, 147 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/server-storage/server-sign-in.component.html b/apps/web/src/app/server-storage/server-sign-in.component.html index 00eda3fdc..f107ace9a 100644 --- a/apps/web/src/app/server-storage/server-sign-in.component.html +++ b/apps/web/src/app/server-storage/server-sign-in.component.html @@ -47,6 +47,9 @@ @case ('unreachable') { {{ 'SERVER_STORAGE.SIGN_IN_UNREACHABLE' | translate }} } + @case ('storage') { + {{ 'SERVER_STORAGE.SIGN_IN_STORAGE' | translate }} + } }

diff --git a/apps/web/src/app/server-storage/server-sign-in.component.spec.ts b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts index 748cf6096..8c055861a 100644 --- a/apps/web/src/app/server-storage/server-sign-in.component.spec.ts +++ b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts @@ -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'); + }); }); diff --git a/apps/web/src/app/server-storage/server-sign-in.component.ts b/apps/web/src/app/server-storage/server-sign-in.component.ts index dd7da1bb3..9b20057fc 100644 --- a/apps/web/src/app/server-storage/server-sign-in.component.ts +++ b/apps/web/src/app/server-storage/server-sign-in.component.ts @@ -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(); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 7ebdbc00c..29d943231 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "الخادم غير متاح", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f91b8aa91..b626bdcca 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "السيرفر ماشي متوفر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7fd5dc3b4..d07cba5d7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Сервер недаступны", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index ec6c3f03d..a05855ddf 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 99c4556e4..54b8b031d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Ο διακομιστής δεν είναι διαθέσιμος", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 737cb022f..672706e4a 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 1082ec182..7c1412554 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index e8c9768a4..07c360da4 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 5f18b52df..2b6d6274b 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index f64b52f64..8a30e0171 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 0ab138bfc..8d7575c64 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "サーバーを利用できません", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index e1e858b43..8eed710ab 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "서버를 사용할 수 없음", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 9da8eb94c..d97da29d8 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index eb9948b85..1418c8ab7 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index b5a678f3e..0aecbdf51 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 7485914ae..575b79139 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Сервер недоступен", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 171acb50b..9149c4d9e 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index f617beb97..6db41d3c9 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "服务器不可用", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2a1d95658..394b8d910 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "伺服器無法使用", diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 8cbd8f4f9..35c29e424 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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()`, diff --git a/libs/services/src/lib/server-access-token.spec.ts b/libs/services/src/lib/server-access-token.spec.ts index 9c78efc31..0887bf5c3 100644 --- a/libs/services/src/lib/server-access-token.spec.ts +++ b/libs/services/src/lib/server-access-token.spec.ts @@ -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); + }); }); diff --git a/libs/services/src/lib/server-access-token.ts b/libs/services/src/lib/server-access-token.ts index 53ed9c27f..1bde6e6dd 100644 --- a/libs/services/src/lib/server-access-token.ts +++ b/libs/services/src/lib/server-access-token.ts @@ -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; } } diff --git a/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts b/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts index b929afc06..3634c1b8a 100644 --- a/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts +++ b/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts @@ -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' }, + }); + }); }); diff --git a/libs/services/src/lib/server-storage/server-migration-xtream.ts b/libs/services/src/lib/server-storage/server-migration-xtream.ts index 0ed64b230..00454f547 100644 --- a/libs/services/src/lib/server-storage/server-migration-xtream.ts +++ b/libs/services/src/lib/server-storage/server-migration-xtream.ts @@ -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 = { ...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; 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 44fb7889f..03bfd1c9d 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 @@ -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); 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 f71c9095e..ffd5886a9 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -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, }); diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts index 89fbe8567..ba9a4289a 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -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;