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

@@ -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' ||