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;