diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 4b5acf96b..0e0f7c6aa 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "تبديل المشغل إلى {{name}}", "SWITCH_PLAYER_DESCRIPTION": "تعيين {{name}} كمشغل الفيديو النشط", "SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "جارٍ تحديث قائمة التشغيل", + "XTREAM_REFRESH_COLLECTING_DATA": "جارٍ حفظ بيانات مكتبتك...", + "XTREAM_REFRESH_DELETING_CONTENT": "جارٍ إزالة البث المخزّن مؤقتًا...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "جارٍ إزالة الفئات المخزّنة مؤقتًا...", + "XTREAM_REFRESH_DETAIL_LOCAL": "نحتفظ بالمفضلة وسجل المشاهدة والفئات المخفية وتقدم التشغيل لاستعادتها بعد المزامنة.", + "XTREAM_REFRESH_PROGRESS": "السجلات المحلية المعالجة: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "المصادر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 4622cb321..30080cdd0 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "بدل المشغل ل{{name}}", "SWITCH_PLAYER_DESCRIPTION": "حدد {{name}} كمشغل الفيديو النشط", "SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "كنحدّثو البلايليست", + "XTREAM_REFRESH_COLLECTING_DATA": "كنحافظو على معطيات المكتبة ديالك...", + "XTREAM_REFRESH_DELETING_CONTENT": "كنحيدو الستريمات المخزنة...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "كنحيدو التصنيفات المخزنة...", + "XTREAM_REFRESH_DETAIL_LOCAL": "كنخليو المفضلات، تاريخ المشاهدة، التصنيفات المخفية، وتقدم التشغيل واجدين باش يرجعو من بعد المزامنة.", + "XTREAM_REFRESH_PROGRESS": "السجلات المحلية اللي تجهزات: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "المصادر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e28d7034b..e21912a25 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Пераключыць прайгравальнік на {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Усталяваць {{name}} як актыўны відэапрайгравальнік", "SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Абнаўленне плэйліста", + "XTREAM_REFRESH_COLLECTING_DATA": "Захаванне даных вашай бібліятэкі...", + "XTREAM_REFRESH_DELETING_CONTENT": "Выдаленне кэшаваных патокаў...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Выдаленне кэшаваных катэгорый...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Выбранае, гісторыя прагляду, схаваныя катэгорыі і прагрэс прайгравання будуць падрыхтаваны для аднаўлення пасля сінхранізацыі.", + "XTREAM_REFRESH_PROGRESS": "Апрацавана лакальных запісаў: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Крыніцы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f0ffeab6a..fdf894652 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Player auf {{name}} wechseln", "SWITCH_PLAYER_DESCRIPTION": "{{name}} als aktiven Videoplayer festlegen", "SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt" - } + }, + "XTREAM_REFRESH_TITLE": "Playlist wird aktualisiert", + "XTREAM_REFRESH_COLLECTING_DATA": "Mediathekdaten werden gesichert...", + "XTREAM_REFRESH_DELETING_CONTENT": "Zwischengespeicherte Streams werden entfernt...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Zwischengespeicherte Kategorien werden entfernt...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Favoriten, Wiedergabeverlauf, ausgeblendete Kategorien und Wiedergabefortschritt werden für die Wiederherstellung nach der Synchronisierung vorbereitet.", + "XTREAM_REFRESH_PROGRESS": "Lokale Einträge verarbeitet: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Quellen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 070208e9b..17515bc63 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Εναλλαγή προγράμματος αναπαραγωγής σε {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Ορισμός του {{name}} ως ενεργό πρόγραμμα αναπαραγωγής βίντεο", "SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Ανανέωση λίστας αναπαραγωγής", + "XTREAM_REFRESH_COLLECTING_DATA": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...", + "XTREAM_REFRESH_DELETING_CONTENT": "Αφαίρεση αποθηκευμένων ροών...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Αφαίρεση αποθηκευμένων κατηγοριών...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Τα αγαπημένα, το ιστορικό προβολής, οι κρυφές κατηγορίες και η πρόοδος αναπαραγωγής ετοιμάζονται για επαναφορά μετά τον συγχρονισμό.", + "XTREAM_REFRESH_PROGRESS": "Τοπικές εγγραφές που επεξεργάστηκαν: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Πηγές", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 0e75ff242..bf4d9f6fe 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Switch player to {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player", "SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Refreshing playlist", + "XTREAM_REFRESH_COLLECTING_DATA": "Preserving your library data...", + "XTREAM_REFRESH_DELETING_CONTENT": "Removing cached streams...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Removing cached categories...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Keeping favorites, watch history, hidden categories, and playback progress ready for restore after sync.", + "XTREAM_REFRESH_PROGRESS": "Local records processed: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Sources", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index d1a82b5d1..3d72143ec 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Cambiar reproductor a {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Establecer {{name}} como reproductor de vídeo activo", "SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Actualizando lista de reproducción", + "XTREAM_REFRESH_COLLECTING_DATA": "Conservando los datos de tu biblioteca...", + "XTREAM_REFRESH_DELETING_CONTENT": "Eliminando streams en caché...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Eliminando categorías en caché...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Guardamos favoritos, historial de reproducción, categorías ocultas y progreso para restaurarlos después de la sincronización.", + "XTREAM_REFRESH_PROGRESS": "Registros locales procesados: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Fuentes", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index dd2580eed..617ac3589 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Basculer le lecteur sur {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Définir {{name}} comme lecteur vidéo actif", "SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Actualisation de la playlist", + "XTREAM_REFRESH_COLLECTING_DATA": "Préservation des données de votre bibliothèque...", + "XTREAM_REFRESH_DELETING_CONTENT": "Suppression des flux en cache...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Suppression des catégories en cache...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Les favoris, l’historique de lecture, les catégories masquées et la progression sont préparés pour être restaurés après la synchronisation.", + "XTREAM_REFRESH_PROGRESS": "Enregistrements locaux traités : {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Sources", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index be9632c17..edba2a83f 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Passa al lettore {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Imposta {{name}} come lettore video attivo", "SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Aggiornamento playlist", + "XTREAM_REFRESH_COLLECTING_DATA": "Conservazione dei dati della libreria...", + "XTREAM_REFRESH_DELETING_CONTENT": "Rimozione degli stream in cache...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Rimozione delle categorie in cache...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Preferiti, cronologia di visione, categorie nascoste e avanzamento di riproduzione vengono preparati per il ripristino dopo la sincronizzazione.", + "XTREAM_REFRESH_PROGRESS": "Record locali elaborati: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Sorgenti", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 9d4305812..70bc74085 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "プレーヤーを{{name}}に切り替え", "SWITCH_PLAYER_DESCRIPTION": "{{name}}をアクティブなビデオプレーヤーに設定", "SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました" - } + }, + "XTREAM_REFRESH_TITLE": "プレイリストを更新中", + "XTREAM_REFRESH_COLLECTING_DATA": "ライブラリデータを保持しています...", + "XTREAM_REFRESH_DELETING_CONTENT": "キャッシュ済みストリームを削除しています...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "キャッシュ済みカテゴリを削除しています...", + "XTREAM_REFRESH_DETAIL_LOCAL": "お気に入り、視聴履歴、非表示カテゴリ、再生進捗を同期後に復元できるよう準備しています。", + "XTREAM_REFRESH_PROGRESS": "処理済みローカルレコード: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "ソース", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 4b7c837f1..3807806b9 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "플레이어를 {{name}}(으)로 전환", "SWITCH_PLAYER_DESCRIPTION": "{{name}}을(를) 활성 비디오 플레이어로 설정", "SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다" - } + }, + "XTREAM_REFRESH_TITLE": "재생목록 새로 고침 중", + "XTREAM_REFRESH_COLLECTING_DATA": "라이브러리 데이터를 보존하는 중...", + "XTREAM_REFRESH_DELETING_CONTENT": "캐시된 스트림을 제거하는 중...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "캐시된 카테고리를 제거하는 중...", + "XTREAM_REFRESH_DETAIL_LOCAL": "즐겨찾기, 시청 기록, 숨긴 카테고리, 재생 진행률을 동기화 후 복원할 수 있도록 준비합니다.", + "XTREAM_REFRESH_PROGRESS": "처리된 로컬 레코드: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "소스", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 0d30e684e..c754b5b0d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Speler wisselen naar {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Stel {{name}} in als de actieve videospeler", "SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Afspeellijst vernieuwen", + "XTREAM_REFRESH_COLLECTING_DATA": "Bibliotheekgegevens worden bewaard...", + "XTREAM_REFRESH_DELETING_CONTENT": "Gecachte streams worden verwijderd...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Gecachte categorieën worden verwijderd...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Favorieten, kijkgeschiedenis, verborgen categorieën en afspeelvoortgang worden klaargezet voor herstel na de synchronisatie.", + "XTREAM_REFRESH_PROGRESS": "Lokale records verwerkt: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Bronnen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index a730f627a..9c6612cb0 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Przełącz odtwarzacz na {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Ustaw {{name}} jako aktywny odtwarzacz wideo", "SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Odświeżanie listy odtwarzania", + "XTREAM_REFRESH_COLLECTING_DATA": "Zachowywanie danych biblioteki...", + "XTREAM_REFRESH_DELETING_CONTENT": "Usuwanie strumieni z pamięci podręcznej...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Usuwanie kategorii z pamięci podręcznej...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Ulubione, historia oglądania, ukryte kategorie i postęp odtwarzania są przygotowywane do przywrócenia po synchronizacji.", + "XTREAM_REFRESH_PROGRESS": "Przetworzone rekordy lokalne: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Źródła", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 2d1d92be6..845f8df4f 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Alternar reprodutor para {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Definir {{name}} como reprodutor de vídeo ativo", "SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Atualizando playlist", + "XTREAM_REFRESH_COLLECTING_DATA": "Preservando os dados da sua biblioteca...", + "XTREAM_REFRESH_DELETING_CONTENT": "Removendo streams em cache...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Removendo categorias em cache...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Favoritos, histórico de visualização, categorias ocultas e progresso de reprodução serão mantidos para restauração após a sincronização.", + "XTREAM_REFRESH_PROGRESS": "Registros locais processados: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Fontes", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 72af1c2f5..5e5ba2f01 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Переключить плеер на {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Сделать {{name}} активным видеоплеером", "SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "Обновление плейлиста", + "XTREAM_REFRESH_COLLECTING_DATA": "Сохраняем данные вашей библиотеки...", + "XTREAM_REFRESH_DELETING_CONTENT": "Удаляем кэшированные потоки...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Удаляем кэшированные категории...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Избранное, история просмотра, скрытые категории и прогресс воспроизведения будут подготовлены для восстановления после синхронизации.", + "XTREAM_REFRESH_PROGRESS": "Обработано локальных записей: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Источники", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index d0460a941..b561f5331 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "Oynatıcıyı {{name}} olarak değiştir", "SWITCH_PLAYER_DESCRIPTION": "{{name}} oynatıcısını etkin video oynatıcı olarak ayarla", "SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı" - } + }, + "XTREAM_REFRESH_TITLE": "Oynatma listesi yenileniyor", + "XTREAM_REFRESH_COLLECTING_DATA": "Kitaplık verileriniz korunuyor...", + "XTREAM_REFRESH_DELETING_CONTENT": "Önbelleğe alınmış yayınlar kaldırılıyor...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "Önbelleğe alınmış kategoriler kaldırılıyor...", + "XTREAM_REFRESH_DETAIL_LOCAL": "Favoriler, izleme geçmişi, gizli kategoriler ve oynatma ilerlemesi eşitlemeden sonra geri yüklenmek üzere hazırlanıyor.", + "XTREAM_REFRESH_PROGRESS": "İşlenen yerel kayıtlar: {{current}} / {{total}}" }, "SOURCES": { "TITLE": "Kaynaklar", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 535e01ad9..6852da029 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "切换播放器为 {{name}}", "SWITCH_PLAYER_DESCRIPTION": "将 {{name}} 设为当前视频播放器", "SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "正在刷新播放列表", + "XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒体库数据...", + "XTREAM_REFRESH_DELETING_CONTENT": "正在移除缓存的流...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "正在移除缓存的分类...", + "XTREAM_REFRESH_DETAIL_LOCAL": "收藏、观看历史、隐藏分类和播放进度会保留,以便同步后恢复。", + "XTREAM_REFRESH_PROGRESS": "已处理本地记录:{{current}} / {{total}}" }, "SOURCES": { "TITLE": "源", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 75b51143e..3282fac06 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -891,7 +891,13 @@ "SWITCH_PLAYER_LABEL": "切換播放器至 {{name}}", "SWITCH_PLAYER_DESCRIPTION": "將 {{name}} 設為使用中的影片播放器", "SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}" - } + }, + "XTREAM_REFRESH_TITLE": "正在重新整理播放清單", + "XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒體庫資料...", + "XTREAM_REFRESH_DELETING_CONTENT": "正在移除快取的串流...", + "XTREAM_REFRESH_DELETING_CATEGORIES": "正在移除快取的分類...", + "XTREAM_REFRESH_DETAIL_LOCAL": "我的最愛、觀看記錄、隱藏分類和播放進度會保留,以便同步後還原。", + "XTREAM_REFRESH_PROGRESS": "已處理本機記錄:{{current}} / {{total}}" }, "SOURCES": { "TITLE": "來源", diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts index 604a6fc77..6bb77cebf 100644 --- a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts @@ -15,6 +15,27 @@ import { Playlist, PlaylistMeta } from 'shared-interfaces'; import { PlaylistContextFacade } from './playlist-context.facade'; import { PlaylistRefreshActionService } from './playlist-refresh-action.service'; +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function createAbortError(): Error { + const error = new Error('Cancelled'); + error.name = 'AbortError'; + return error; +} + +async function waitForRefreshPreparationPaint(): Promise { + await new Promise((resolve) => setTimeout(resolve, 160)); +} + function createPlaylistMeta( overrides: Partial = {} ): PlaylistMeta { @@ -239,6 +260,141 @@ describe('PlaylistRefreshActionService', () => { dateNowSpy.mockRestore(); }); + it('sets refresh-preparation state immediately after Xtream refresh confirmation', async () => { + const item = createPlaylistMeta(); + const refresh = createDeferred<{ + success: boolean; + favorites: []; + recentlyViewed: []; + hiddenCategories: []; + }>(); + let confirmPromise: Promise | undefined; + + databaseService.deleteXtreamPlaylistContent.mockReturnValue( + refresh.promise + ); + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + + service.refresh(item); + + expect(service.refreshPreparation()).toEqual({ + playlistId: item._id, + operationId: 'xtream-refresh-op', + phase: 'collecting-user-data', + }); + + refresh.resolve({ + success: true, + favorites: [], + recentlyViewed: [], + hiddenCategories: [], + }); + await confirmPromise; + + expect(service.refreshPreparation()).toBeNull(); + }); + + it('updates refresh-preparation phase and progress from worker events', async () => { + const item = createPlaylistMeta(); + const refresh = createDeferred<{ + success: boolean; + favorites: []; + recentlyViewed: []; + hiddenCategories: []; + }>(); + let confirmPromise: Promise | undefined; + + databaseService.deleteXtreamPlaylistContent.mockImplementation( + ( + _playlistId: string, + options?: { + operationId?: string; + onEvent?: (event: any) => void; + } + ) => { + options?.onEvent?.({ + operation: 'delete-xtream-content', + operationId: 'xtream-refresh-op', + status: 'progress', + phase: 'deleting-content', + current: 50, + total: 100, + }); + + return refresh.promise; + } + ); + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + + service.refresh(item); + await waitForRefreshPreparationPaint(); + + expect(service.refreshPreparation()).toEqual({ + playlistId: item._id, + operationId: 'xtream-refresh-op', + phase: 'deleting-content', + current: 50, + total: 100, + }); + + refresh.resolve({ + success: true, + favorites: [], + recentlyViewed: [], + hiddenCategories: [], + }); + await confirmPromise; + }); + + it.each([ + ['abort', createAbortError()], + ['error', new Error('Refresh failed')], + ])( + 'clears refresh-preparation state after Xtream refresh %s', + async (_caseName, error) => { + const item = createPlaylistMeta(); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(); + const refresh = createDeferred<{ + success: boolean; + favorites: []; + recentlyViewed: []; + hiddenCategories: []; + }>(); + let confirmPromise: Promise | undefined; + + databaseService.deleteXtreamPlaylistContent.mockReturnValue( + refresh.promise + ); + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + + try { + service.refresh(item); + expect(service.refreshPreparation()).not.toBeNull(); + + refresh.reject(error); + await confirmPromise; + + expect(service.refreshPreparation()).toBeNull(); + } finally { + consoleErrorSpy.mockRestore(); + } + } + ); + it('marks the active M3U route as loading before refreshing and clears loading after the update action', async () => { const item = createPlaylistMeta({ _id: 'playlist-1', diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts index 202da3301..833ad9540 100644 --- a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts @@ -6,6 +6,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { DialogService } from 'components'; import { DatabaseService, + type DbOperationEvent, isDbAbortError, PlaybackPositionService, PlaylistRefreshService, @@ -15,6 +16,14 @@ import { ChannelActions, PlaylistActions } from 'm3u-state'; import { PlaylistMeta } from 'shared-interfaces'; import { PlaylistContextFacade } from './playlist-context.facade'; +export interface XtreamRefreshPreparationState { + playlistId: string; + operationId: string; + phase: string; + current?: number; + total?: number; +} + @Injectable({ providedIn: 'root' }) export class PlaylistRefreshActionService { private readonly router = inject(Router); @@ -30,7 +39,11 @@ export class PlaylistRefreshActionService { XtreamPendingRestoreService ); + private readonly refreshPreparationState = + signal(null); + readonly isRefreshing = signal(false); + readonly refreshPreparation = this.refreshPreparationState.asReadonly(); canRefresh(playlist: PlaylistMeta | null): boolean { if (!playlist || !window.electron) { @@ -69,6 +82,11 @@ export class PlaylistRefreshActionService { this.isRefreshing.set(true); const operationId = this.databaseService.createOperationId('xtream-refresh'); + this.refreshPreparationState.set({ + playlistId: item._id, + operationId, + phase: 'collecting-user-data', + }); try { this.snackBar.open( @@ -78,13 +96,22 @@ export class PlaylistRefreshActionService { undefined, { duration: 2000 } ); + await this.waitForRefreshPreparationPaint(); const updateDate = Date.now(); const [restoreState, playbackPositions] = await Promise.all( [ this.databaseService.deleteXtreamPlaylistContent( item._id, - { operationId } + { + operationId, + onEvent: (event) => + this.updateRefreshPreparationFromEvent( + item._id, + operationId, + event + ), + } ), this.playbackPositionService.getAllPlaybackPositions( item._id @@ -127,6 +154,7 @@ export class PlaylistRefreshActionService { ); } } finally { + this.clearRefreshPreparation(operationId); this.isRefreshing.set(false); } }, @@ -208,4 +236,53 @@ export class PlaylistRefreshActionService { return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); } + + private updateRefreshPreparationFromEvent( + playlistId: string, + operationId: string, + event: DbOperationEvent + ): void { + if (event.operationId && event.operationId !== operationId) { + return; + } + + this.refreshPreparationState.update((current) => { + if (!current || current.operationId !== operationId) { + return current; + } + + return { + playlistId, + operationId, + phase: event.phase ?? current.phase, + current: event.current ?? current.current, + total: event.total ?? current.total, + }; + }); + } + + private clearRefreshPreparation(operationId: string): void { + this.refreshPreparationState.update((current) => + current?.operationId === operationId ? null : current + ); + } + + private async waitForRefreshPreparationPaint(): Promise { + const minimumVisibleDelayMs = 120; + + // Give Angular a paint opportunity before small cached playlists can + // finish cleanup and clear the preparation state. + const resolveAfterDelay = (resolve: () => void): void => { + setTimeout(resolve, minimumVisibleDelayMs); + }; + + if (typeof requestAnimationFrame !== 'function') { + await new Promise(resolveAfterDelay); + return; + } + + await new Promise((resolve) => { + requestAnimationFrame(() => resolveAfterDelay(resolve)); + }); + } } 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 5007d4ad0..64dad4ab2 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 @@ -1,7 +1,14 @@ -import { XtreamImportPhaseTone } from './workspace-shell-constants'; -import { TranslateFn } from './workspace-shell-search-labels'; +import type { XtreamImportPhaseTone } from './workspace-shell-constants'; +import type { TranslateFn } from './workspace-shell-search-labels'; export type XtreamImportContentType = 'live' | 'vod' | 'series' | null; +export type XtreamRefreshPreparationPhase = + | 'collecting-user-data' + | 'deleting-content' + | 'deleting-categories' + | string + | null + | undefined; export function buildXtreamImportTypeLabel( contentType: XtreamImportContentType, @@ -39,6 +46,24 @@ export function buildXtreamImportPhaseTone( } } +export function buildXtreamRefreshPreparationPhaseLabel( + phase: XtreamRefreshPreparationPhase, + translate: TranslateFn +): string { + switch (phase) { + case 'collecting-user-data': + return translate('WORKSPACE.SHELL.XTREAM_REFRESH_COLLECTING_DATA'); + case 'deleting-content': + return translate('WORKSPACE.SHELL.XTREAM_REFRESH_DELETING_CONTENT'); + case 'deleting-categories': + return translate( + 'WORKSPACE.SHELL.XTREAM_REFRESH_DELETING_CATEGORIES' + ); + default: + return translate('WORKSPACE.SHELL.XTREAM_REFRESH_COLLECTING_DATA'); + } +} + export function buildXtreamImportSourceLabel( tone: XtreamImportPhaseTone, translate: TranslateFn @@ -109,6 +134,22 @@ export function buildXtreamImportProgressLabel( }); } +export function buildXtreamRefreshPreparationProgressLabel( + currentCount: number, + totalCount: number, + translate: TranslateFn, + formatNumber: (value: number) => string +): string { + if (totalCount === 0) { + return ''; + } + + return translate('WORKSPACE.SHELL.XTREAM_REFRESH_PROGRESS', { + current: formatNumber(currentCount), + total: formatNumber(totalCount), + }); +} + export function formatLocalizedNumber( value: number, currentLang: string | null | undefined, 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 b611756e7..984a38596 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 @@ -2,8 +2,9 @@ import { computed, inject, Injectable } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; +import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { XtreamImportPhaseTone } from './helpers/workspace-shell-constants'; +import type { XtreamImportPhaseTone } from './helpers/workspace-shell-constants'; import { buildXtreamImportDetailLabel, buildXtreamImportPhaseLabel, @@ -11,12 +12,17 @@ import { buildXtreamImportProgressLabel, buildXtreamImportSourceLabel, buildXtreamImportTypeLabel, + buildXtreamRefreshPreparationPhaseLabel, + buildXtreamRefreshPreparationProgressLabel, formatLocalizedNumber, } from './helpers/workspace-shell-import-labels'; @Injectable() export class WorkspaceShellXtreamImportService { private readonly xtreamStore = inject(XtreamStore); + private readonly playlistRefreshAction = inject( + PlaylistRefreshActionService + ); private readonly translate = inject(TranslateService); private readonly isElectron = !!window.electron; @@ -32,10 +38,22 @@ export class WorkspaceShellXtreamImportService { readonly xtreamImportCount = this.xtreamStore.getImportCount; readonly xtreamItemsToImport = this.xtreamStore.itemsToImport; - readonly xtreamActiveImportCount = - this.xtreamStore.activeImportCurrentCount; - readonly xtreamActiveItemsToImport = - this.xtreamStore.activeImportTotalCount; + readonly refreshPreparation = this.playlistRefreshAction.refreshPreparation; + readonly activeRefreshPreparation = computed( + () => this.refreshPreparation() ?? null + ); + readonly xtreamActiveImportCount = computed(() => { + const preparation = this.activeRefreshPreparation(); + return preparation + ? (preparation.current ?? 0) + : this.xtreamStore.activeImportCurrentCount(); + }); + readonly xtreamActiveItemsToImport = computed(() => { + const preparation = this.activeRefreshPreparation(); + return preparation + ? (preparation.total ?? 0) + : this.xtreamStore.activeImportTotalCount(); + }); readonly xtreamImportPhase = this.xtreamStore.currentImportPhase; readonly isCancellingXtreamImport = this.xtreamStore.isCancellingImport; @@ -47,9 +65,13 @@ export class WorkspaceShellXtreamImportService { !this.xtreamStore.isCancellingImport() ); - readonly xtreamImportTitleLabel = computed(() => - this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_TITLE') - ); + readonly xtreamImportTitleLabel = computed(() => { + if (this.activeRefreshPreparation()) { + return this.translateText('WORKSPACE.SHELL.XTREAM_REFRESH_TITLE'); + } + + return this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_TITLE'); + }); readonly xtreamImportTypeLabel = computed(() => { this.languageTick(); @@ -59,24 +81,42 @@ export class WorkspaceShellXtreamImportService { ); }); - readonly xtreamImportProgressLabel = computed(() => - buildXtreamImportProgressLabel( + readonly xtreamImportProgressLabel = computed(() => { + const formatNumber = (value: number): string => + formatLocalizedNumber( + value, + this.translate.currentLang, + this.translate.defaultLang + ); + const preparation = this.activeRefreshPreparation(); + + if (preparation) { + return buildXtreamRefreshPreparationProgressLabel( + this.xtreamActiveImportCount(), + this.xtreamActiveItemsToImport(), + this.translateText, + formatNumber + ); + } + + return buildXtreamImportProgressLabel( this.xtreamImportTypeLabel(), this.xtreamActiveImportCount(), this.xtreamActiveItemsToImport(), this.translateText, - (value) => - formatLocalizedNumber( - value, - this.translate.currentLang, - this.translate.defaultLang - ) - ) - ); + formatNumber + ); + }); - readonly xtreamImportPhaseTone = computed(() => - buildXtreamImportPhaseTone(this.xtreamStore.currentImportPhase()) - ); + readonly xtreamImportPhaseTone = computed(() => { + if (this.activeRefreshPreparation()) { + return 'local'; + } + + return buildXtreamImportPhaseTone( + this.xtreamStore.currentImportPhase() + ); + }); readonly xtreamImportSourceLabel = computed(() => { this.languageTick(); @@ -88,6 +128,15 @@ export class WorkspaceShellXtreamImportService { readonly xtreamImportPhaseLabel = computed(() => { this.languageTick(); + const preparation = this.activeRefreshPreparation(); + + if (preparation) { + return buildXtreamRefreshPreparationPhaseLabel( + preparation.phase, + this.translateText + ); + } + return buildXtreamImportPhaseLabel( this.xtreamStore.currentImportPhase(), this.translateText @@ -96,6 +145,12 @@ export class WorkspaceShellXtreamImportService { readonly xtreamImportDetailLabel = computed(() => { this.languageTick(); + if (this.activeRefreshPreparation()) { + return this.translateText( + 'WORKSPACE.SHELL.XTREAM_REFRESH_DETAIL_LOCAL' + ); + } + return buildXtreamImportDetailLabel( this.xtreamImportPhaseTone(), this.translateText @@ -107,6 +162,13 @@ export class WorkspaceShellXtreamImportService { !this.xtreamStore.contentInitBlockReason() && this.xtreamStore.isImporting() ); + readonly isRefreshPreparationRunning = computed(() => + Boolean(this.activeRefreshPreparation()) + ); + + isRefreshPreparationRunningForPlaylist(playlistId: string): boolean { + return this.refreshPreparation()?.playlistId === playlistId; + } cancelXtreamImport(): void { void this.xtreamStore.cancelImport(); 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 1adf387f8..0f569b249 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 @@ -5,7 +5,11 @@ import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + PlaylistContextFacade, + PlaylistRefreshActionService, + type XtreamRefreshPreparationState, +} from '@iptvnator/playlist/shared/util'; import { PORTAL_EXTERNAL_PLAYBACK, WorkspaceHeaderContextService, @@ -34,6 +38,7 @@ class MockXtreamStore { readonly categorySearchTerm = signal(''); readonly isImporting = signal(false); readonly isCancellingImport = signal(false); + readonly contentInitBlockReason = signal(null); readonly activeImportSessionId = signal(null); readonly currentImportPhase = signal(null); readonly activeImportContentType = signal<'live' | 'vod' | 'series' | null>( @@ -123,6 +128,9 @@ describe('WorkspaceShellFacade', () => { typeof signal >; let playlistsSignal: ReturnType>; + let refreshPreparationSignal: ReturnType< + typeof signal + >; let stalkerStore: MockStalkerStore; let showDashboardSignal: ReturnType>; let startupPreferences: { @@ -145,6 +153,9 @@ describe('WorkspaceShellFacade', () => { { _id: 'pl-1', serverUrl: 'http://example.com' }, { _id: 'pl-2', macAddress: '00:11:22:33' }, ]); + refreshPreparationSignal = signal( + null + ); router = { url: '/workspace/xtreams/pl-1/vod', @@ -230,6 +241,16 @@ describe('WorkspaceShellFacade', () => { activePlaylist: activePlaylistSignal, }, }, + { + provide: PlaylistRefreshActionService, + useValue: { + canRefresh: jest.fn(() => true), + isRefreshing: signal(false), + refreshPreparation: + refreshPreparationSignal.asReadonly(), + refresh: jest.fn(), + }, + }, { provide: SettingsStore, useValue: { @@ -270,6 +291,14 @@ describe('WorkspaceShellFacade', () => { return `${params.type} imported: ${params.current} / ${params.total}`; } + if ( + key === + 'WORKSPACE.SHELL.XTREAM_REFRESH_PROGRESS' && + params + ) { + return `Local records processed: ${params.current} / ${params.total}`; + } + return key; }, get: (key: string) => of(key), @@ -349,6 +378,73 @@ describe('WorkspaceShellFacade', () => { ); }); + it('shows the Xtream overlay during refresh preparation for the active playlist', () => { + refreshPreparationSignal.set({ + playlistId: 'pl-1', + operationId: 'xtream-refresh-op', + phase: 'collecting-user-data', + }); + + expect(facade.showXtreamImportOverlay()).toBe(true); + }); + + it('shows the Xtream overlay during refresh preparation on the dashboard', () => { + facade.currentUrl.set('/workspace/dashboard'); + refreshPreparationSignal.set({ + playlistId: 'dashboard-xtream-playlist', + operationId: 'xtream-refresh-op', + phase: 'collecting-user-data', + }); + + expect(facade.showXtreamImportOverlay()).toBe(true); + }); + + it('does not show the Xtream overlay for another playlist refresh preparation', () => { + refreshPreparationSignal.set({ + playlistId: 'other-playlist', + operationId: 'xtream-refresh-op', + phase: 'collecting-user-data', + }); + + expect(facade.showXtreamImportOverlay()).toBe(false); + }); + + it('prefers refresh-preparation labels over import labels', () => { + const xtreamStore = TestBed.inject( + XtreamStore + ) as unknown as MockXtreamStore; + const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService); + + xtreamStore.isImporting.set(true); + xtreamStore.currentImportPhase.set('loading-categories'); + xtreamStore.activeImportContentType.set('vod'); + xtreamStore.activeImportCurrentCount.set(2); + xtreamStore.activeImportTotalCount.set(20); + refreshPreparationSignal.set({ + playlistId: 'pl-1', + operationId: 'xtream-refresh-op', + phase: 'deleting-content', + current: 5, + total: 10, + }); + + expect(xtreamImport.xtreamImportTitleLabel()).toBe( + 'WORKSPACE.SHELL.XTREAM_REFRESH_TITLE' + ); + expect(xtreamImport.xtreamImportSourceLabel()).toBe( + 'WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE' + ); + expect(xtreamImport.xtreamImportPhaseLabel()).toBe( + 'WORKSPACE.SHELL.XTREAM_REFRESH_DELETING_CONTENT' + ); + expect(xtreamImport.xtreamImportDetailLabel()).toBe( + 'WORKSPACE.SHELL.XTREAM_REFRESH_DETAIL_LOCAL' + ); + expect(xtreamImport.xtreamImportProgressLabel()).toBe( + 'Local records processed: 5 / 10' + ); + }); + it('builds a type-aware xtream import progress label', () => { const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index ff03a6dbe..90fbbd6bb 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -267,15 +267,29 @@ export class WorkspaceShellFacade { ); private readonly xtreamImport = inject(WorkspaceShellXtreamImportService); readonly showXtreamImportOverlay = computed(() => { + const route = this.currentRoute(); const context = this.currentContext(); const section = this.currentSection(); + const hasRefreshPreparation = Boolean( + this.xtreamImport.refreshPreparation() + ); + + if (route.kind === 'dashboard') { + return hasRefreshPreparation; + } if (context?.provider !== 'xtreams') { return false; } + const isPreparingCurrentPlaylist = + this.xtreamImport.isRefreshPreparationRunningForPlaylist( + context.playlistId + ); + return ( - this.xtreamImport.isImportRunning() && + (this.xtreamImport.isImportRunning() || + isPreparingCurrentPlaylist) && (section === 'vod' || section === 'live' || section === 'series' ||