mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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();
|
||||
|
||||
@@ -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": "الخادم غير متاح",
|
||||
|
||||
@@ -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": "السيرفر ماشي متوفر",
|
||||
|
||||
@@ -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": "Сервер недаступны",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ο διακομιστής δεν είναι διαθέσιμος",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "サーバーを利用できません",
|
||||
|
||||
@@ -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": "서버를 사용할 수 없음",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сервер недоступен",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "服务器不可用",
|
||||
|
||||
@@ -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": "伺服器無法使用",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user