refactor(playlists): move refresh action to ui boundary

This commit is contained in:
4gray committed 2026-05-22 11:27:04 +03:00
1 parent 45eba8e685
commit 820fbef121
11 files changed
+22 -21

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/recent-playlists/empty-state/empty-state.component';
export * from './lib/playlist-refresh-action.service';
@@ -0,0 +1,464 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { DialogService } from '@iptvnator/ui/components';
import {
DatabaseService,
DbOperationEvent,
PlaybackPositionService,
PlaylistRefreshService,
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
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 {
return {
_id: 'playlist-1',
title: 'Xtream Playlist',
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
...overrides,
} as PlaylistMeta;
}
describe('PlaylistRefreshActionService', () => {
let service: PlaylistRefreshActionService;
let databaseService: {
createOperationId: jest.Mock;
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let router: {
navigate: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: {
dispatch: jest.Mock;
};
let playlistRefreshService: {
refreshPlaylist: jest.Mock;
};
let playbackPositionService: {
getAllPlaybackPositions: jest.Mock;
};
let routeProvider: ReturnType<
typeof signal<'playlists' | 'xtreams' | null>
>;
let resolvedPlaylistId: ReturnType<typeof signal<string | null>>;
beforeEach(() => {
localStorage.clear();
databaseService = {
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
success: true,
favorites: [
{
xtreamId: 101,
contentType: 'live',
},
{
xtreamId: 202,
contentType: 'movie',
},
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-04T08:00:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
}),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
router = {
navigate: jest.fn().mockResolvedValue(true),
};
snackBar = {
open: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
playlistRefreshService = {
refreshPlaylist: jest.fn(),
};
playbackPositionService = {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
};
routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams');
resolvedPlaylistId = signal<string | null>(null);
TestBed.configureTestingModule({
providers: [
PlaylistRefreshActionService,
{
provide: Router,
useValue: router,
},
{
provide: Store,
useValue: store,
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: PlaylistRefreshService,
useValue: playlistRefreshService,
},
{
provide: PlaybackPositionService,
useValue: playbackPositionService,
},
{
provide: PlaylistContextFacade,
useValue: {
routeProvider,
resolvedPlaylistId,
},
},
],
});
service = TestBed.inject(PlaylistRefreshActionService);
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it('treats file-backed M3U playlists as refreshable in Electron', () => {
window.electron = { platform: 'darwin' } as typeof window.electron;
expect(
service.canRefresh(
createPlaylistMeta({
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/local-source.m3u',
})
)
).toBe(true);
});
it('does not expose filesystem refresh outside Electron', () => {
window.electron = undefined as unknown as typeof window.electron;
expect(
service.canRefresh(
createPlaylistMeta({
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/local-source.m3u',
})
)
).toBe(false);
});
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
const item = createPlaylistMeta();
const executionOrder: string[] = [];
let confirmPromise: Promise<void> | undefined;
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712217600000);
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (key: string, value: string) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
store.dispatch.mockImplementation((action: unknown) => {
executionOrder.push('dispatch');
return action;
});
router.navigate.mockImplementation((commands: unknown[]) => {
executionOrder.push('navigate');
return Promise.resolve(Boolean(commands));
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
service.refresh(item);
await confirmPromise;
expect(dialogService.openConfirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
width: '400px',
})
);
expect(setItemSpy).toHaveBeenCalledWith(
`xtream-restore-${item._id}`,
JSON.stringify({
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
favorites: [
{
xtreamId: 101,
contentType: 'live',
},
{
xtreamId: 202,
contentType: 'movie',
},
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-04T08:00:00.000Z',
},
],
playbackPositions: [],
})
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712217600000 },
})
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
setItemSpy.mockRestore();
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: DbOperationEvent) => 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',
filePath: '/tmp/playlist.m3u',
serverUrl: undefined,
url: 'https://example.com/playlist.m3u',
});
const refreshedPlaylist = {
_id: item._id,
playlist: {
items: [],
},
} as Playlist;
routeProvider.set('playlists');
resolvedPlaylistId.set(item._id);
playlistRefreshService.refreshPlaylist.mockResolvedValue(
refreshedPlaylist
);
service.refresh(item);
await Promise.resolve();
await Promise.resolve();
expect(store.dispatch).toHaveBeenNthCalledWith(
1,
ChannelActions.setChannelsLoading({ loading: true })
);
expect(store.dispatch).toHaveBeenNthCalledWith(
2,
PlaylistActions.updatePlaylist({
playlist: refreshedPlaylist,
playlistId: item._id,
})
);
});
});
@@ -0,0 +1,288 @@
import { inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DialogService } from '@iptvnator/ui/components';
import {
DatabaseService,
type DbOperationEvent,
isDbAbortError,
PlaybackPositionService,
PlaylistRefreshService,
XtreamPendingRestoreService,
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
export interface XtreamRefreshPreparationState {
playlistId: string;
operationId: string;
phase: string;
current?: number;
total?: number;
}
@Injectable({ providedIn: 'root' })
export class PlaylistRefreshActionService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly playbackPositionService = inject(PlaybackPositionService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly pendingRestoreService = inject(
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) {
return false;
}
return Boolean(playlist.serverUrl || playlist.url || playlist.filePath);
}
refresh(playlist: PlaylistMeta): void {
if (this.isRefreshing()) {
return;
}
if (playlist.serverUrl) {
this.refreshXtream(playlist);
} else if (playlist.url || playlist.filePath) {
void this.refreshM3u(playlist);
}
}
private refreshXtream(item: PlaylistMeta): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.TITLE'
),
message: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
),
width: '400px',
onConfirm: async () => {
if (this.isRefreshing()) {
return;
}
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(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
),
undefined,
{ duration: 2000 }
);
await this.waitForRefreshPreparationPaint();
const updateDate = Date.now();
const [restoreState, playbackPositions] = await Promise.all(
[
this.databaseService.deleteXtreamPlaylistContent(
item._id,
{
operationId,
onEvent: (event) =>
this.updateRefreshPreparationFromEvent(
item._id,
operationId,
event
),
}
),
this.playbackPositionService.getAllPlaybackPositions(
item._id
),
this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
}),
]
);
this.pendingRestoreService.set(item._id, {
...restoreState,
playbackPositions,
});
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
);
await this.router.navigate([
'/workspace',
'xtreams',
item._id,
]);
} catch (error) {
if (!isDbAbortError(error)) {
console.error(
'Error refreshing Xtream playlist:',
error
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
),
undefined,
{ duration: 3000 }
);
}
} finally {
this.clearRefreshPreparation(operationId);
this.isRefreshing.set(false);
}
},
});
}
private async refreshM3u(item: PlaylistMeta): Promise<void> {
const isActiveM3uRoute =
this.playlistContext.routeProvider() === 'playlists' &&
this.playlistContext.resolvedPlaylistId() === item._id;
this.isRefreshing.set(true);
if (isActiveM3uRoute) {
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: true })
);
}
try {
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist({
operationId:
this.databaseService.createOperationId(
'playlist-refresh'
),
playlistId: item._id,
title: item.title,
url: item.url,
filePath: item.filePath,
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
})
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
if (isActiveM3uRoute) {
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: false })
);
}
} finally {
this.isRefreshing.set(false);
}
}
private getRefreshErrorMessage(error: unknown, item: PlaylistMeta): string {
if (
error instanceof Error &&
error.message?.includes('ENOENT') &&
item.filePath
) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
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));
});
}
}
@@ -12,16 +12,14 @@ import { Store } from '@ngrx/store';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
PlaylistContextFacade,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/util';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { DialogService } from '@iptvnator/ui/components';
import {
PlaylistDeleteActionService,
PortalStatusService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
import { PlaylistSwitcherComponent } from './playlist-switcher.component';
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
@@ -27,10 +27,7 @@ import { normalizeDateLocale } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService } from '@iptvnator/ui/components';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
PlaylistContextFacade,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/util';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PlaylistDeleteActionService,
PortalStatus,
@@ -38,6 +35,7 @@ import {
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { startWith } from 'rxjs';
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';