diff --git a/.changes/workspace-sync-overlay-local-phase.md b/.changes/workspace-sync-overlay-local-phase.md new file mode 100644 index 000000000..477c56a2d --- /dev/null +++ b/.changes/workspace-sync-overlay-local-phase.md @@ -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. diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8dc608feb..b10bf92f0 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "جارٍ استعادة المفضلة من المكتبة المحلية...", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 7f8a4dc63..64feda99f 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "جاري استرجاع المفضلة من المكتبة المحلية...", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index c700eb2ca..aea483e55 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Аднаўленне выбранага з лакальнай бібліятэкі...", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 7379c62aa..2b164fc0c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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...", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 5d79a7ddd..f50be139b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Επαναφορά αγαπημένων από την τοπική βιβλιοθήκη...", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index d354d8a03..c908f2fa0 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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...", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index f1a6802fe..6959a7fe2 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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…", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index b74eb8086..d9d4c4ff3 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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…", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 4f41acc5f..d83846d65 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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…", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8e728760f..b0f143440 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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...", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 5e29290a7..d1e9885da 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "ローカルライブラリからお気に入りを復元中...", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ae9eb9cb9..631c938ff 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "로컬 라이브러리에서 즐겨찾기를 복원하는 중...", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 5031f2e9f..af5250773 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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...", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 5ba87e878..1ed478cd3 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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...", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 4361aa9ed..5e9370751 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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...", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 884541a6e..f18e817aa 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Восстановление избранного из локальной библиотеки...", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 9b77c9023..9972b8cdd 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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...", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index f704117f6..dd8e65302 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "正在从本地库恢复收藏…", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index c7afdbbfa..0fe2c5d58 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "正在從本機媒體庫還原我的最愛...", diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts index a4c157a62..92f7376d1 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts @@ -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' }, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 7dac4bdcc..2251c7b0b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -157,6 +157,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { 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 { + // 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. diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 52cec9325..51a0673b2 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -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', diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 2a3eb8525..fab1a3724 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -296,7 +296,6 @@ export class PwaXtreamDataSource implements IXtreamDataSource { type: CategoryType, options?: XtreamOperationOptions ): Promise { - 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 diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.import-phase.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.import-phase.spec.ts new file mode 100644 index 000000000..1da2256ae --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.import-phase.spec.ts @@ -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, []>; + +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; + 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(); + 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(); + dataSource.getContent.mockImplementation(() => gate.promise); + + const initialization = store.initializeContent(); + await waitForCondition( + () => store.importPhase() === 'loading-categories' + ); + + expect(store.isImporting()).toBe(true); + + gate.resolve([]); + await initialization; + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 4dbe1492d..6e30ed48c 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -222,6 +222,12 @@ export function withContent() { const xtreamApiService = inject(XtreamApiService); const importTypes: ContentType[] = ['live', 'vod', 'series']; let activeInitializationPromise: Promise | 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(); 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); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 3843df70b..fc2927f01 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -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': diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-import-labels.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-import-labels.ts index 64dad4ab2..ebb567a6b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-import-labels.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-import-labels.ts @@ -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'); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts index 35c64202a..ffe24f15c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts @@ -184,6 +184,7 @@ export class WorkspaceShellXtreamImportService { } return buildXtreamImportDetailLabel( + this.xtreamStore.currentImportPhase(), this.xtreamImportPhaseTone(), this.translateText ); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 3ad9b87b3..504d0f989 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -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',