refactor: improve Xtream refresh functionality with localization support

- Introduced new localization keys for Xtream refresh actions in multiple languages (ar, ary, by, de, el, en, es, fr, it, ja, ko, nl, pl, pt, ru, tr, zh, zhtw).
- Implemented refresh preparation state management in PlaylistRefreshActionService.
- Enhanced WorkspaceShellXtreamImportService to handle refresh preparation states and display appropriate labels.
- Updated tests to cover new refresh preparation scenarios and ensure correct overlay display during refresh operations.

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-04 00:51:29 +02:00
1 parent e36c953865
commit d0f590d8fe
24 files changed
+598 -44

No files matched your search

+7 -1
View File
@@ -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": "المصادر",
+7 -1
View File
@@ -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": "المصادر",
+7 -1
View File
@@ -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": "Крыніцы",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "Πηγές",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "ソース",
+7 -1
View File
@@ -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": "소스",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "Источники",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "源",
+7 -1
View File
@@ -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": "來源",
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 160));
}
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): 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<void> | undefined;
databaseService.deleteXtreamPlaylistContent.mockReturnValue(
refresh.promise
);
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
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<void> | 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<void> }) => {
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<void> | undefined;
databaseService.deleteXtreamPlaylistContent.mockReturnValue(
refresh.promise
);
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
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',
@@ -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<XtreamRefreshPreparationState | null>(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<void> {
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<void>(resolveAfterDelay);
return;
}
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolveAfterDelay(resolve));
});
}
}
@@ -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,
@@ -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<XtreamImportPhaseTone>(() =>
buildXtreamImportPhaseTone(this.xtreamStore.currentImportPhase())
);
readonly xtreamImportPhaseTone = computed<XtreamImportPhaseTone>(() => {
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();
@@ -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<string | null>(null);
readonly currentImportPhase = signal<string | null>(null);
readonly activeImportContentType = signal<'live' | 'vod' | 'series' | null>(
@@ -123,6 +128,9 @@ describe('WorkspaceShellFacade', () => {
typeof signal<PlaylistSignalMeta | null>
>;
let playlistsSignal: ReturnType<typeof signal<PlaylistSignalMeta[]>>;
let refreshPreparationSignal: ReturnType<
typeof signal<XtreamRefreshPreparationState | null>
>;
let stalkerStore: MockStalkerStore;
let showDashboardSignal: ReturnType<typeof signal<boolean>>;
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<XtreamRefreshPreparationState | null>(
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);
@@ -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' ||