fix(workspace): report a local phase while reading the cached Xtream catalog (#1345)

* fix(workspace): report a local phase while reading the cached Xtream catalog

Since #1311 the sync overlay is shown for the whole import session, but the
DB-first read path never emitted an import phase, so switching to an
already-imported Xtream playlist showed a bare "Syncing playlist" card with
no badge or description. The Electron data source now reports a
'loading-cached' phase (local-library badge, its own label and detail text)
before reading categories/content from SQLite, and the PWA data source
reports the remote loading phases on API fetches it previously swallowed.

Adds the two new i18n keys to en.json and all 18 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): keep the loading-cached phase from marking a real import

The store's onPhaseChange callbacks set isImporting unconditionally, and the
initialization error path gates import-cache cleanup on that flag — so a
cancelled or failed warm SQLite read would have wiped the healthy cached
catalog and forced a full provider redownload. The shared publishImportPhase
helper now publishes 'loading-cached' as a presentation-only phase; any
remote/save phase still marks the import as running. Adds regression specs
(verified to fail against the previous behavior) in a dedicated spec file to
stay under the test max-lines limit.

Addresses Codex P1 review feedback on #1345.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): scope cancelled-import cleanup to types with remote work

A session-wide isImporting flag meant that once any content type contacted
the provider, cancelling during a later cache-only read cleared the healthy
cached catalogs of every not-yet-completed type. Cleanup now consults a
per-session set of types that actually performed remote or save work
(populated from typed phase callbacks and save-content events), so
cache-only types keep their catalogs on cancellation while genuinely
partial types are still cleared. Mixed-scenario regression spec added
(mutation-verified against the unguarded behavior).

Addresses the second Codex P1 on #1345.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-02 13:08:19 +02:00
1 parent 011f322807
commit 8f9e78ff90
30 files changed
+463 -32

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: workspace
---
The playlist sync overlay no longer shows a bare "Syncing playlist" card when
an Xtream playlist is loaded from the local library. Reading the saved catalog
from this device now reports its own phase, so the overlay always shows a
source badge and explains what is happening — remote fetch or local read.
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "مكتبة محلية",
"XTREAM_IMPORT_DETAIL_REMOTE": "بانتظار استجابة المزود.",
"XTREAM_IMPORT_DETAIL_LOCAL": "تحضير تبديل أسرع لقائمة التشغيل في الزيارة القادمة.",
"XTREAM_IMPORT_DETAIL_CACHED": "جارٍ قراءة المكتبة المحلية — لا يتم تنزيل أي بيانات من المزود.",
"XTREAM_IMPORT_PREPARING": "جارٍ تحضير المكتبة المحلية...",
"XTREAM_IMPORT_LOADING_CACHED": "جارٍ تحميل الكتالوج المحفوظ من هذا الجهاز...",
"XTREAM_IMPORT_LOADING": "جارٍ جلب بيانات قائمة التشغيل من المصدر...",
"XTREAM_IMPORT_SAVING": "جارٍ حفظ بيانات قائمة التشغيل محليًا...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "جارٍ استعادة المفضلة من المكتبة المحلية...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "المكتبة المحلية",
"XTREAM_IMPORT_DETAIL_REMOTE": "كنتسناو رد المزود.",
"XTREAM_IMPORT_DETAIL_LOCAL": "كنوجدو تبديل أسرع لقوائم التشغيل للزيارة الجاية.",
"XTREAM_IMPORT_DETAIL_CACHED": "جاري قراءة المكتبة المحلية — ما شي بيانات كتتنزلو من المزود.",
"XTREAM_IMPORT_PREPARING": "جاري تحضير المكتبة المحلية...",
"XTREAM_IMPORT_LOADING_CACHED": "جاري تشريج الفهرس المحفوظ من الجهاز ديالك...",
"XTREAM_IMPORT_LOADING": "جاري جلب بيانات قائمة التشغيل من المصدر...",
"XTREAM_IMPORT_SAVING": "جاري حفظ بيانات قائمة التشغيل محلياً...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "جاري استرجاع المفضلة من المكتبة المحلية...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Лакальная бібліятэка",
"XTREAM_IMPORT_DETAIL_REMOTE": "Чаканне адказу ад правайдэра.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Падрыхтоўка хуткага пераключэння плэйлістоў для наступнага візіту.",
"XTREAM_IMPORT_DETAIL_CACHED": "Чытанне лакальнай бібліятэкі — даныя не загружаюцца ад правайдэра.",
"XTREAM_IMPORT_PREPARING": "Падрыхтоўка лакальнай бібліятэкі...",
"XTREAM_IMPORT_LOADING_CACHED": "Загрузка захаванага каталога з гэтай прылады...",
"XTREAM_IMPORT_LOADING": "Атрыманне дадзеных плэйліста з крыніцы...",
"XTREAM_IMPORT_SAVING": "Захаванне дадзеных плэйліста лакальна...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Аднаўленне выбранага з лакальнай бібліятэкі...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Lokale Bibliothek",
"XTREAM_IMPORT_DETAIL_REMOTE": "Warte auf die Antwort des Providers.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Bereitet schnellere Playlist-Wechsel für den nächsten Besuch vor.",
"XTREAM_IMPORT_DETAIL_CACHED": "Lokaler Katalog wird gelesen – keine Daten vom Anbieter heruntergeladen",
"XTREAM_IMPORT_PREPARING": "Lokale Bibliothek wird vorbereitet...",
"XTREAM_IMPORT_LOADING_CACHED": "Gespeicherter Katalog wird von diesem Gerät geladen…",
"XTREAM_IMPORT_LOADING": "Playlist-Daten werden von der Quelle geladen...",
"XTREAM_IMPORT_SAVING": "Playlist-Daten werden lokal gespeichert...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Favoriten werden aus der lokalen Bibliothek wiederhergestellt...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Τοπική βιβλιοθήκη",
"XTREAM_IMPORT_DETAIL_REMOTE": "Αναμονή απάντησης από τον πάροχο.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Προετοιμασία γρηγορότερης εναλλαγής λίστας αναπαραγωγής για την επόμενη επίσκεψη.",
"XTREAM_IMPORT_DETAIL_CACHED": "Ανάγνωση της τοπικής βιβλιοθήκης — δεν γίνονται λήψεις δεδομένων από τον πάροχο.",
"XTREAM_IMPORT_PREPARING": "Προετοιμασία τοπικής βιβλιοθήκης...",
"XTREAM_IMPORT_LOADING_CACHED": "Φόρτωση του αποθηκευμένου καταλόγου από αυτήν τη συσκευή...",
"XTREAM_IMPORT_LOADING": "Λήψη δεδομένων λίστας αναπαραγωγής από την πηγή...",
"XTREAM_IMPORT_SAVING": "Αποθήκευση δεδομένων λίστας αναπαραγωγής τοπικά...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Επαναφορά αγαπημένων από την τοπική βιβλιοθήκη...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Local library",
"XTREAM_IMPORT_DETAIL_REMOTE": "Waiting on the provider response.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparing faster playlist switching for the next visit.",
"XTREAM_IMPORT_DETAIL_CACHED": "Reading the local library — no data is downloaded from the provider.",
"XTREAM_IMPORT_PREPARING": "Preparing local library...",
"XTREAM_IMPORT_LOADING_CACHED": "Loading the saved catalog from this device...",
"XTREAM_IMPORT_LOADING": "Fetching playlist data from source...",
"XTREAM_IMPORT_SAVING": "Saving playlist data locally...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restoring favorites from local library...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Biblioteca local",
"XTREAM_IMPORT_DETAIL_REMOTE": "Esperando la respuesta del proveedor.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparando un cambio de lista más rápido para la próxima visita.",
"XTREAM_IMPORT_DETAIL_CACHED": "Leyendo la biblioteca local — sin descargar datos del proveedor.",
"XTREAM_IMPORT_PREPARING": "Preparando biblioteca local…",
"XTREAM_IMPORT_LOADING_CACHED": "Cargando el catálogo guardado desde este dispositivo...",
"XTREAM_IMPORT_LOADING": "Obteniendo datos de la lista desde la fuente…",
"XTREAM_IMPORT_SAVING": "Guardando datos de la lista localmente…",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restaurando favoritos desde la biblioteca local…",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Bibliothèque locale",
"XTREAM_IMPORT_DETAIL_REMOTE": "En attente de la réponse du fournisseur.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Préparation d'une bascule plus rapide entre listes de lecture pour la prochaine visite.",
"XTREAM_IMPORT_DETAIL_CACHED": "Lecture depuis la bibliothèque locale — aucune donnée n'est téléchargée depuis le fournisseur.",
"XTREAM_IMPORT_PREPARING": "Préparation de la bibliothèque locale…",
"XTREAM_IMPORT_LOADING_CACHED": "Chargement du catalogue sauvegardé de cet appareil…",
"XTREAM_IMPORT_LOADING": "Récupération des données de la liste depuis la source…",
"XTREAM_IMPORT_SAVING": "Enregistrement des données de la liste localement…",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restauration des favoris depuis la bibliothèque locale…",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Helyi médiatár",
"XTREAM_IMPORT_DETAIL_REMOTE": "Várakozás a szolgáltató válaszára.",
"XTREAM_IMPORT_DETAIL_LOCAL": "A lejátszási listák gyorsabb későbbi megnyitásának előkészítése.",
"XTREAM_IMPORT_DETAIL_CACHED": "A helyi könyvtár beolvasása — a szolgáltatótól nem töltődnek le adatok.",
"XTREAM_IMPORT_PREPARING": "Helyi médiatár előkészítése…",
"XTREAM_IMPORT_LOADING_CACHED": "Az eszközön mentett katalógus betöltése…",
"XTREAM_IMPORT_LOADING": "Lejátszási lista adatainak lekérése a forrásból…",
"XTREAM_IMPORT_SAVING": "Lejátszási lista adatainak helyi mentése…",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Kedvencek visszaállítása a helyi médiatárból…",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Libreria locale",
"XTREAM_IMPORT_DETAIL_REMOTE": "In attesa della risposta del fornitore.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparazione di un cambio playlist più rapido per la prossima visita.",
"XTREAM_IMPORT_DETAIL_CACHED": "Lettura della libreria locale — nessun dato viene scaricato dal fornitore.",
"XTREAM_IMPORT_PREPARING": "Preparazione libreria locale...",
"XTREAM_IMPORT_LOADING_CACHED": "Caricamento del catalogo salvato da questo dispositivo...",
"XTREAM_IMPORT_LOADING": "Recupero dati playlist dalla sorgente...",
"XTREAM_IMPORT_SAVING": "Salvataggio dati playlist in locale...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Ripristino preferiti dalla libreria locale...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "ローカルライブラリ",
"XTREAM_IMPORT_DETAIL_REMOTE": "プロバイダーからの応答を待っています。",
"XTREAM_IMPORT_DETAIL_LOCAL": "次回のためにプレイリスト切り替えを高速化する準備をしています。",
"XTREAM_IMPORT_DETAIL_CACHED": "ローカルキャッシュを読み込み中 — プロバイダーからはデータをダウンロードしていません。",
"XTREAM_IMPORT_PREPARING": "ローカルライブラリを準備中...",
"XTREAM_IMPORT_LOADING_CACHED": "このデバイスに保存されたカタログを読み込み中...",
"XTREAM_IMPORT_LOADING": "ソースからプレイリストデータを取得中...",
"XTREAM_IMPORT_SAVING": "プレイリストデータをローカルに保存中...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "ローカルライブラリからお気に入りを復元中...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "로컬 라이브러리",
"XTREAM_IMPORT_DETAIL_REMOTE": "공급자 응답을 기다리는 중입니다.",
"XTREAM_IMPORT_DETAIL_LOCAL": "다음 방문 시 더 빠른 재생목록 전환을 위해 준비하는 중입니다.",
"XTREAM_IMPORT_DETAIL_CACHED": "로컬 카탈로그를 읽는 중 — 공급자에서 데이터를 내려받지 않습니다.",
"XTREAM_IMPORT_PREPARING": "로컬 라이브러리를 준비하는 중...",
"XTREAM_IMPORT_LOADING_CACHED": "이 기기에서 저장된 카탈로그를 불러오는 중...",
"XTREAM_IMPORT_LOADING": "소스에서 재생목록 데이터를 가져오는 중...",
"XTREAM_IMPORT_SAVING": "재생목록 데이터를 로컬에 저장하는 중...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "로컬 라이브러리에서 즐겨찾기를 복원하는 중...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Lokale bibliotheek",
"XTREAM_IMPORT_DETAIL_REMOTE": "Wachten op antwoord van de provider.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Sneller schakelen tussen afspeellijsten voorbereiden voor het volgende bezoek.",
"XTREAM_IMPORT_DETAIL_CACHED": "Lezen uit de lokale bibliotheek — er worden geen gegevens van de provider gedownload.",
"XTREAM_IMPORT_PREPARING": "Lokale bibliotheek voorbereiden...",
"XTREAM_IMPORT_LOADING_CACHED": "Gecachte catalogus van dit apparaat laden...",
"XTREAM_IMPORT_LOADING": "Afspeellijstgegevens ophalen van de bron...",
"XTREAM_IMPORT_SAVING": "Afspeellijstgegevens lokaal opslaan...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Favorieten herstellen vanuit lokale bibliotheek...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Biblioteka lokalna",
"XTREAM_IMPORT_DETAIL_REMOTE": "Oczekiwanie na odpowiedź dostawcy.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Przygotowywanie szybszego przełączania list odtwarzania na następną wizytę.",
"XTREAM_IMPORT_DETAIL_CACHED": "Odczytywanie biblioteki lokalnej — dane nie są pobierane od dostawcy.",
"XTREAM_IMPORT_PREPARING": "Przygotowywanie biblioteki lokalnej...",
"XTREAM_IMPORT_LOADING_CACHED": "Wczytywanie zapisanego katalogu z tego urządzenia...",
"XTREAM_IMPORT_LOADING": "Pobieranie danych listy odtwarzania ze źródła...",
"XTREAM_IMPORT_SAVING": "Zapisywanie danych listy odtwarzania lokalnie...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Przywracanie ulubionych z biblioteki lokalnej...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Biblioteca local",
"XTREAM_IMPORT_DETAIL_REMOTE": "Aguardando resposta do provedor.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparando troca mais rápida de playlist para a próxima visita.",
"XTREAM_IMPORT_DETAIL_CACHED": "Lendo a biblioteca local — nenhum dado é baixado do provedor.",
"XTREAM_IMPORT_PREPARING": "Preparando biblioteca local...",
"XTREAM_IMPORT_LOADING_CACHED": "Carregando o catálogo salvo deste dispositivo...",
"XTREAM_IMPORT_LOADING": "Buscando dados da playlist na fonte...",
"XTREAM_IMPORT_SAVING": "Salvando dados da playlist localmente...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restaurando favoritos da biblioteca local...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Локальная библиотека",
"XTREAM_IMPORT_DETAIL_REMOTE": "Ожидание ответа от провайдера.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Подготавливает более быстрые переключения плейлиста при следующем открытии.",
"XTREAM_IMPORT_DETAIL_CACHED": "Чтение локальной библиотеки — данные от провайдера не загружаются.",
"XTREAM_IMPORT_PREPARING": "Подготовка локальной библиотеки...",
"XTREAM_IMPORT_LOADING_CACHED": "Загрузка сохранённого каталога с этого устройства...",
"XTREAM_IMPORT_LOADING": "Получение данных плейлиста из источника...",
"XTREAM_IMPORT_SAVING": "Сохранение данных плейлиста локально...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Восстановление избранного из локальной библиотеки...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "Yerel kütüphane",
"XTREAM_IMPORT_DETAIL_REMOTE": "Sağlayıcı yanıtı bekleniyor.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Bir sonraki ziyaret için daha hızlı oynatma listesi geçişi hazırlanıyor.",
"XTREAM_IMPORT_DETAIL_CACHED": "Yerel katalog okunuyor — sağlayıcıdan veri indirilmiyor.",
"XTREAM_IMPORT_PREPARING": "Yerel kütüphane hazırlanıyor...",
"XTREAM_IMPORT_LOADING_CACHED": "Bu cihazdan kaydedilmiş katalog yükleniyor...",
"XTREAM_IMPORT_LOADING": "Kaynaktan oynatma listesi verileri alınıyor...",
"XTREAM_IMPORT_SAVING": "Oynatma listesi verileri yerel olarak kaydediliyor...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Yerel kütüphaneden favoriler geri yükleniyor...",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "本地库",
"XTREAM_IMPORT_DETAIL_REMOTE": "正在等待提供商响应。",
"XTREAM_IMPORT_DETAIL_LOCAL": "正在为下次访问准备更快的播放列表切换。",
"XTREAM_IMPORT_DETAIL_CACHED": "正在读取本地库——不从提供商下载数据。",
"XTREAM_IMPORT_PREPARING": "正在准备本地库…",
"XTREAM_IMPORT_LOADING_CACHED": "正在从此设备加载已保存的目录...",
"XTREAM_IMPORT_LOADING": "正在从源获取播放列表数据…",
"XTREAM_IMPORT_SAVING": "正在本地保存播放列表数据…",
"XTREAM_IMPORT_RESTORING_FAVORITES": "正在从本地库恢复收藏…",
+2
View File
@@ -1296,7 +1296,9 @@
"XTREAM_IMPORT_LOCAL_BADGE": "本機媒體庫",
"XTREAM_IMPORT_DETAIL_REMOTE": "正在等待供應商回應。",
"XTREAM_IMPORT_DETAIL_LOCAL": "正在為下次造訪準備更快速的播放清單切換。",
"XTREAM_IMPORT_DETAIL_CACHED": "讀取本機媒體庫——不會從供應商下載任何資料。",
"XTREAM_IMPORT_PREPARING": "正在準備本機媒體庫...",
"XTREAM_IMPORT_LOADING_CACHED": "正在從此裝置載入已儲存的目錄...",
"XTREAM_IMPORT_LOADING": "正在從來源擷取播放清單資料...",
"XTREAM_IMPORT_SAVING": "正在將播放清單資料儲存至本機...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "正在從本機媒體庫還原我的最愛...",
@@ -88,11 +88,32 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
);
expect(result).toEqual([dbCategory]);
expect(onPhaseChange.mock.calls).toEqual([
['loading-cached'],
['loading-categories'],
['saving-categories'],
]);
});
it('reports the loading-cached phase when categories come from the DB', async () => {
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'completed'
);
harness.dbService.getXtreamCategories.mockResolvedValue([
dbCategory,
]);
const onPhaseChange = jest.fn();
await harness.dataSource.getCategories(
playlistId,
credentials,
'live',
{ onPhaseChange }
);
expect(onPhaseChange.mock.calls).toEqual([['loading-cached']]);
expect(harness.apiService.getCategories).not.toHaveBeenCalled();
});
it('refetches from the API when cached rows exist but the import never completed', async () => {
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'importing'
@@ -217,6 +238,28 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
expect(harness.apiService.getStreams).not.toHaveBeenCalled();
});
it('reports the loading-cached phase when content comes from the DB', async () => {
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'completed'
);
harness.dbService.getXtreamContent.mockResolvedValue([
dbContentItem,
]);
const onPhaseChange = jest.fn();
await harness.dataSource.getContent(
playlistId,
credentials,
'live',
undefined,
undefined,
{ onPhaseChange }
);
expect(onPhaseChange.mock.calls).toEqual([['loading-cached']]);
expect(harness.apiService.getStreams).not.toHaveBeenCalled();
});
it('fetches from the API, reports progress, and caches on a cold cache', async () => {
const remoteStreams = [
{ stream_id: 101, name: 'News Live' },
@@ -157,6 +157,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
const importType = this.mapCategoryTypeToImportType(type);
// The DB read below is the slow part of a warm start; report it as a
// phase so the sync overlay never shows a phaseless card.
options?.onPhaseChange?.('loading-cached');
const importStatus = await this.getImportStatus(playlistId, importType);
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
@@ -299,6 +302,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
// The DB read below is the slow part of a warm start; report it as a
// phase so the sync overlay never shows a phaseless card.
options?.onPhaseChange?.('loading-cached');
const importStatus = await this.getImportStatus(playlistId, type);
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
@@ -53,6 +53,37 @@ describe('PwaXtreamDataSource', () => {
localStorage.clear();
});
it('reports remote loading phases for API fetches but stays silent on cache hits', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'News' },
]);
const onPhaseChange = jest.fn();
await dataSource.getContent(
'playlist-1',
credentials,
'movie',
undefined,
undefined,
{ onPhaseChange }
);
expect(onPhaseChange.mock.calls).toEqual([['loading-movies']]);
onPhaseChange.mockClear();
await dataSource.getContent(
'playlist-1',
credentials,
'movie',
undefined,
undefined,
{ onPhaseChange }
);
expect(onPhaseChange).not.toHaveBeenCalled();
expect(apiService.getStreams).toHaveBeenCalledTimes(1);
});
it('keeps Xtream passwords out of localStorage playlist metadata', async () => {
await dataSource.createPlaylist({
id: 'playlist-1',
@@ -296,7 +296,6 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategory[]> {
void options;
const cacheKey = `${playlistId}-${type}-categories`;
// Check in-memory cache first
@@ -306,6 +305,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
// Fetch from API
options?.onPhaseChange?.('loading-categories');
const categories = await this.apiService.getCategories(
credentials,
type
@@ -381,7 +381,6 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
| XtreamSerieItem[]
| XtreamContentItem[]
> {
void options;
const cacheKey = `${playlistId}-${type}-content`;
// Check in-memory cache first
@@ -394,6 +393,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
// Fetch from API
options?.onPhaseChange?.(
type === 'live'
? 'loading-live'
: type === 'movie'
? 'loading-movies'
: 'loading-series'
);
const content = this.normalizeContentItems(
await this.apiService.getStreams(credentials, type),
type
@@ -0,0 +1,247 @@
import { TestBed } from '@angular/core/testing';
import {
DatabaseService,
XtreamPendingRestoreService,
} from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import {
createAbortError,
createContentTestStore,
createDeferred,
createPendingRestoreServiceMock,
waitForCondition,
} from './with-content.feature.spec-helpers';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
type ContentType = 'live' | 'movie' | 'series';
let checkPortalStatusMock: jest.Mock<Promise<PortalStatusType>, []>;
const TestContentStore = createContentTestStore(() => checkPortalStatusMock());
type PhaseOptions = { onPhaseChange?: (phase: string) => void };
// The 'loading-cached' phase is presentation-only: it drives the sync overlay
// labels but must never mark a real import as started, because the error path
// gates import-cache cleanup on isImporting.
describe('withContent loading-cached phase', () => {
let store: InstanceType<typeof TestContentStore>;
let dataSource: {
getCategories: jest.Mock;
getCachedCategories: jest.Mock;
getContent: jest.Mock;
getCachedContent: jest.Mock;
hasCategories: jest.Mock;
hasContent: jest.Mock;
restoreUserData: jest.Mock;
};
let databaseService: {
clearXtreamImportCache: jest.Mock;
cancelOperation: jest.Mock;
createOperationId: jest.Mock;
getXtreamImportStatus: jest.Mock;
setXtreamImportStatus: jest.Mock;
supportsDbOperationCancellation: jest.Mock;
};
beforeEach(() => {
localStorage.clear();
let operationCounter = 0;
dataSource = {
getCategories: jest.fn().mockResolvedValue([]),
getCachedCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn().mockResolvedValue([]),
getCachedContent: jest.fn().mockResolvedValue([]),
hasCategories: jest.fn().mockResolvedValue(true),
hasContent: jest.fn().mockResolvedValue(false),
restoreUserData: jest.fn().mockResolvedValue(undefined),
};
databaseService = {
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest
.fn()
.mockImplementation((prefix?: string) => {
if (prefix === 'xtream-import-session') {
return 'xtream-import-session';
}
operationCounter += 1;
return `${prefix ?? 'db-op'}-${operationCounter}`;
}),
getXtreamImportStatus: jest.fn().mockResolvedValue('completed'),
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
supportsDbOperationCancellation: jest.fn().mockReturnValue(true),
};
checkPortalStatusMock = jest.fn().mockResolvedValue('active');
TestBed.configureTestingModule({
providers: [
TestContentStore,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XtreamApiService,
useValue: { cancelSession: jest.fn().mockResolvedValue(true) },
},
{
provide: XtreamPendingRestoreService,
useValue: createPendingRestoreServiceMock(),
},
],
});
store = TestBed.inject(TestContentStore);
});
afterEach(() => {
localStorage.clear();
});
it('publishes the loading-cached phase without marking the import as running', async () => {
const gate = createDeferred<unknown[]>();
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
_type: string,
options?: PhaseOptions
) => {
options?.onPhaseChange?.('loading-cached');
return gate.promise;
}
);
const initialization = store.initializeContent();
await waitForCondition(() => store.importPhase() === 'loading-cached');
expect(store.isImporting()).toBe(false);
gate.resolve([]);
await initialization;
});
it('keeps a cancelled warm cached read out of import cache cleanup', async () => {
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
_type: string,
options?: PhaseOptions
) => {
options?.onPhaseChange?.('loading-cached');
return Promise.resolve([]);
}
);
dataSource.getContent.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: ContentType,
_onProgress?: (count: number) => void,
_onTotal?: (total: number) => void,
options?: PhaseOptions
) => {
options?.onPhaseChange?.('loading-cached');
if (type === 'movie') {
return Promise.reject(createAbortError());
}
return Promise.resolve([]);
}
);
await store.initializeContent();
// The warm start never touched the provider, so its cancellation must
// not wipe the healthy cached catalog.
expect(databaseService.clearXtreamImportCache).not.toHaveBeenCalled();
expect(store.contentInitBlockReason()).toBe('cancelled');
});
it('clears only the type that performed remote work when a mixed import is cancelled', async () => {
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
_type: string,
options?: PhaseOptions
) => {
options?.onPhaseChange?.('loading-cached');
return Promise.resolve([]);
}
);
dataSource.getContent.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: ContentType,
_onProgress?: (count: number) => void,
_onTotal?: (total: number) => void,
options?: PhaseOptions
) => {
if (type === 'live') {
// Live misses the cache, contacts the provider, then the
// user cancels mid-download.
options?.onPhaseChange?.('loading-live');
return Promise.reject(createAbortError());
}
options?.onPhaseChange?.('loading-cached');
return Promise.resolve([]);
}
);
await store.initializeContent();
// Only live held partial remote data; vod/series were cache-only and
// must keep their healthy catalogs.
expect(databaseService.clearXtreamImportCache).toHaveBeenCalledTimes(1);
expect(databaseService.clearXtreamImportCache).toHaveBeenCalledWith(
expect.any(String),
'live'
);
});
it('still marks the import as running once a remote phase is reported', async () => {
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
_type: string,
options?: PhaseOptions
) => {
options?.onPhaseChange?.('loading-cached');
options?.onPhaseChange?.('loading-categories');
return Promise.resolve([]);
}
);
const gate = createDeferred<unknown[]>();
dataSource.getContent.mockImplementation(() => gate.promise);
const initialization = store.initializeContent();
await waitForCondition(
() => store.importPhase() === 'loading-categories'
);
expect(store.isImporting()).toBe(true);
gate.resolve([]);
await initialization;
});
});
@@ -222,6 +222,12 @@ export function withContent() {
const xtreamApiService = inject(XtreamApiService);
const importTypes: ContentType[] = ['live', 'vod', 'series'];
let activeInitializationPromise: Promise<void> | null = null;
// Types that actually contacted the provider (or saved remote
// data) in the current initialization. Cancellation cleanup must
// only clear these: a type served entirely from the local cache
// has nothing partial to clean up, and clearing it would throw
// away a healthy catalog and force a full redownload.
const sessionRemoteWorkTypes = new Set<ContentType>();
let cachedHydrationGeneration = 0;
const activeCachedHydrationPromises = new Map<
string,
@@ -672,6 +678,28 @@ export function withContent() {
return request;
};
const publishImportPhase = (phase: string): void => {
patchState(store, (state) => ({
// 'loading-cached' is a read-only presentation phase. It
// must not mark a real import as started: the error path
// gates cache cleanup on isImporting, so flagging a warm
// DB read would let a cancellation wipe the healthy
// cached catalog and force a full provider redownload.
isImporting:
state.isImporting || phase !== 'loading-cached',
importPhase: phase,
}));
};
const publishTypedImportPhase =
(type: ContentType) =>
(phase: string): void => {
if (phase !== 'loading-cached') {
sessionRemoteWorkTypes.add(type);
}
publishImportPhase(phase);
};
const trackImportEvent = (event: DbOperationEvent): void => {
const operationId = event.operationId;
@@ -717,6 +745,12 @@ export function withContent() {
event.operation === 'save-content' &&
store.activeImportContentType()
) {
// A save event proves this type is writing remote data,
// independent of the loading phase that preceded it.
const activeType = store.activeImportContentType();
if (activeType) {
sessionRemoteWorkTypes.add(activeType);
}
patchState(store, (state) => ({
activeImportCurrentCount:
event.current ?? state.activeImportCurrentCount,
@@ -769,6 +803,14 @@ export function withContent() {
continue;
}
// Only types that performed remote/save work this session
// can hold partial data. A type still pending because it
// was being read from the local cache keeps its healthy
// catalog instead of being cleared into a redownload.
if (!sessionRemoteWorkTypes.has(type)) {
continue;
}
await setImportStatus(playlistId, type, status);
await clearImportCache(playlistId, type);
}
@@ -830,6 +872,7 @@ export function withContent() {
'xtream-import-session'
);
sessionRemoteWorkTypes.clear();
patchState(store, {
isImporting: false,
isCancellingImport: false,
@@ -1059,11 +1102,7 @@ export function withContent() {
'live',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
onPhaseChange: publishTypedImportPhase('live'),
}
),
dataSource.getCategories(
@@ -1072,11 +1111,7 @@ export function withContent() {
'vod',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
onPhaseChange: publishTypedImportPhase('vod'),
}
),
dataSource.getCategories(
@@ -1085,11 +1120,7 @@ export function withContent() {
'series',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
onPhaseChange: publishTypedImportPhase('series'),
}
),
]);
@@ -1172,11 +1203,7 @@ export function withContent() {
operationId: liveOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
onPhaseChange: publishTypedImportPhase('live'),
}
)) as XtreamLiveStream[];
throwIfImportCancelled(options?.importSessionId);
@@ -1213,11 +1240,7 @@ export function withContent() {
operationId: vodOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
onPhaseChange: publishTypedImportPhase('vod'),
}
)) as XtreamVodStream[];
throwIfImportCancelled(options?.importSessionId);
@@ -1255,11 +1278,7 @@ export function withContent() {
operationId: seriesOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
onPhaseChange: publishTypedImportPhase('series'),
}
)) as XtreamSerieItem[];
throwIfImportCancelled(options?.importSessionId);
@@ -461,6 +461,8 @@ export class WorkspaceContextPanelComponent {
private getXtreamImportPhaseLabelKey(phase: string | null): string {
switch (phase) {
case 'loading-cached':
return 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING_CACHED';
case 'preparing-content':
return 'WORKSPACE.SHELL.XTREAM_IMPORT_PREPARING';
case 'loading-categories':
@@ -35,6 +35,7 @@ export function buildXtreamImportPhaseTone(
case 'loading-movies':
case 'loading-series':
return 'remote';
case 'loading-cached':
case 'preparing-content':
case 'saving-categories':
case 'saving-content':
@@ -82,6 +83,8 @@ export function buildXtreamImportPhaseLabel(
translate: TranslateFn
): string {
switch (phase) {
case 'loading-cached':
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_LOADING_CACHED');
case 'preparing-content':
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_PREPARING');
case 'loading-categories':
@@ -104,9 +107,16 @@ export function buildXtreamImportPhaseLabel(
}
export function buildXtreamImportDetailLabel(
phase: string | null | undefined,
tone: XtreamImportPhaseTone,
translate: TranslateFn
): string {
// Reading the already-imported catalog from SQLite is a local phase, but
// the generic local detail ("prepares faster switches next time") would
// misdescribe it — the cache is what is being read, not built.
if (phase === 'loading-cached') {
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_CACHED');
}
if (tone === 'remote') {
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE');
}
@@ -184,6 +184,7 @@ export class WorkspaceShellXtreamImportService {
}
return buildXtreamImportDetailLabel(
this.xtreamStore.currentImportPhase(),
this.xtreamImportPhaseTone(),
this.translateText
);
@@ -458,6 +458,25 @@ describe('WorkspaceShellFacade', () => {
);
});
it('describes a local-library read while cached content is loading', () => {
const xtreamStore = TestBed.inject(
XtreamStore
) as unknown as MockXtreamStore;
const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService);
xtreamStore.currentImportPhase.set('loading-cached');
expect(xtreamImport.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING_CACHED'
);
expect(xtreamImport.xtreamImportSourceLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE'
);
expect(xtreamImport.xtreamImportDetailLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_CACHED'
);
});
it('shows the Xtream overlay during refresh preparation for the active playlist', () => {
refreshPreparationSignal.set({
playlistId: 'pl-1',