refactor(playlists): centralize delete lifecycle

This commit is contained in:
4gray committed 2026-05-22 11:10:04 +03:00
1 parent 6a4c4c49c2
commit b2778d8b18
8 files changed
+209 -143

No files matched your search

@@ -11,7 +11,6 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
PlaylistContextFacade,
@@ -19,8 +18,7 @@ import {
} from '@iptvnator/playlist/shared/util';
import { DialogService } from '@iptvnator/ui/components';
import {
DatabaseService,
PlaylistsService,
PlaylistDeleteActionService,
PortalStatusService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -75,11 +73,7 @@ describe('PlaylistSwitcherComponent', () => {
let dialogService: {
openConfirmDialog: jest.Mock;
};
let databaseService: {
createOperationId: jest.Mock;
deletePlaylist: jest.Mock;
};
let playlistsService: {
let playlistDeleteAction: {
deletePlaylist: jest.Mock;
};
let snackBar: {
@@ -133,6 +127,10 @@ describe('PlaylistSwitcherComponent', () => {
provide: PlaylistRefreshActionService,
useValue: refreshActionService,
},
{
provide: PlaylistDeleteActionService,
useValue: playlistDeleteAction,
},
{
provide: MatDialog,
useValue: dialog,
@@ -141,14 +139,6 @@ describe('PlaylistSwitcherComponent', () => {
provide: DialogService,
useValue: dialogService,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: MatSnackBar,
useValue: snackBar,
@@ -208,12 +198,8 @@ describe('PlaylistSwitcherComponent', () => {
dialogService = {
openConfirmDialog: jest.fn(),
};
databaseService = {
createOperationId: jest.fn(),
deletePlaylist: jest.fn(),
};
playlistsService = {
deletePlaylist: jest.fn(() => of({ success: true })),
playlistDeleteAction = {
deletePlaylist: jest.fn().mockResolvedValue(true),
};
snackBar = {
open: jest.fn(),
@@ -371,12 +357,7 @@ describe('PlaylistSwitcherComponent', () => {
expect(component.displayTitle()).toBe('Select playlist');
});
it('deletes browser/PWA playlists through PlaylistsService instead of the Electron database service', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: undefined,
});
it('delegates playlist deletion and removes the source after confirmation', async () => {
await createComponent();
component.removePlaylistFor(xtreamPlaylist);
@@ -384,10 +365,9 @@ describe('PlaylistSwitcherComponent', () => {
.onConfirm as () => Promise<void>;
await confirm();
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
xtreamPlaylist._id
expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(
xtreamPlaylist
);
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({
playlistId: xtreamPlaylist._id,
@@ -32,14 +32,12 @@ import {
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/util';
import {
DatabaseService,
PlaylistsService,
PlaylistDeleteActionService,
PortalStatus,
PortalStatusService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { firstValueFrom, startWith } from 'rxjs';
import { startWith } from 'rxjs';
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
@@ -79,9 +77,7 @@ export class PlaylistSwitcherComponent {
private readonly refreshAction = inject(PlaylistRefreshActionService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
@@ -315,9 +311,8 @@ export class PlaylistSwitcherComponent {
private async removePlaylistConfirmed(
playlist: PlaylistMeta
): Promise<void> {
const deleted = this.runtime.isElectron
? await this.deletePlaylistInElectron(playlist)
: await this.deletePlaylistInBrowser(playlist);
const deleted =
await this.playlistDeleteAction.deletePlaylist(playlist);
if (!deleted) {
return;
@@ -333,28 +328,6 @@ export class PlaylistSwitcherComponent {
);
}
private async deletePlaylistInElectron(
playlist: PlaylistMeta
): Promise<boolean> {
const operationId = playlist.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
return this.databaseService.deletePlaylist(
playlist._id,
operationId ? { operationId } : undefined
);
}
private async deletePlaylistInBrowser(
playlist: PlaylistMeta
): Promise<boolean> {
const result = await firstValueFrom(
this.playlistsService.deletePlaylist(playlist._id)
);
return result.success;
}
getPlaylistIcon(playlist: PlaylistMeta): string {
if (playlist.macAddress) {
return 'dashboard';
@@ -17,8 +17,9 @@ import { DialogService } from '@iptvnator/ui/components';
import {
DatabaseService,
DataService,
DbOperationEvent,
PlaybackPositionService,
PlaylistsService,
PlaylistDeleteActionService,
SortBy,
SortOrder,
SortService,
@@ -77,7 +78,7 @@ describe('RecentPlaylistsComponent busy state', () => {
let playbackPositionService: {
getAllPlaybackPositions: jest.Mock;
};
let playlistsService: {
let playlistDeleteAction: {
deletePlaylist: jest.Mock;
};
let router: {
@@ -110,8 +111,8 @@ describe('RecentPlaylistsComponent busy state', () => {
playbackPositionService = {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
};
playlistsService = {
deletePlaylist: jest.fn(),
playlistDeleteAction = {
deletePlaylist: jest.fn().mockResolvedValue(true),
};
router = {
navigate: jest.fn(),
@@ -152,8 +153,8 @@ describe('RecentPlaylistsComponent busy state', () => {
useValue: playbackPositionService,
},
{
provide: PlaylistsService,
useValue: playlistsService,
provide: PlaylistDeleteActionService,
useValue: playlistDeleteAction,
},
{
provide: PlaylistContextFacade,
@@ -215,12 +216,11 @@ describe('RecentPlaylistsComponent busy state', () => {
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
const deletion = createDeferred<boolean>();
databaseService.deletePlaylist.mockImplementation(
playlistDeleteAction.deletePlaylist.mockImplementation(
(
_playlistId: string,
_playlist: PlaylistMeta,
options?: {
onEvent?: (event: any) => void;
operationId?: string;
onEvent?: (event: DbOperationEvent) => void;
}
) => {
options?.onEvent?.({
@@ -243,6 +243,9 @@ describe('RecentPlaylistsComponent busy state', () => {
);
expect(component.getBusyProgress(item._id)).toBe(25);
expect(component.canCancelBusyOperation(item)).toBe(true);
expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(item, {
onEvent: expect.any(Function),
});
await component.cancelBusyOperation(item);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
@@ -264,7 +267,7 @@ describe('RecentPlaylistsComponent busy state', () => {
);
});
it('deletes browser/PWA playlists through PlaylistsService instead of the Electron database service', async () => {
it('delegates playlist deletion and updates local UI state after success', async () => {
const item = createPlaylistMeta({
_id: 'pwa-playlist-1',
serverUrl: undefined,
@@ -272,17 +275,13 @@ describe('RecentPlaylistsComponent busy state', () => {
password: undefined,
url: 'https://example.com/playlist.m3u',
});
window.electron = undefined as unknown as typeof window.electron;
(
component as unknown as {
isElectron: boolean;
}
).isElectron = false;
playlistsService.deletePlaylist.mockReturnValue(of({ success: true }));
playlistDeleteAction.deletePlaylist.mockResolvedValue(true);
await component.removePlaylist(item);
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(item._id);
expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(item, {
onEvent: expect.any(Function),
});
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({ playlistId: item._id })
@@ -321,7 +320,7 @@ describe('RecentPlaylistsComponent busy state', () => {
(
_playlistId: string,
options?: {
onEvent?: (event: any) => void;
onEvent?: (event: DbOperationEvent) => void;
operationId?: string;
}
) => {
@@ -26,7 +26,7 @@ import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { BehaviorSubject, combineLatest, firstValueFrom, map } from 'rxjs';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DialogService } from '@iptvnator/ui/components';
import {
DatabaseService,
@@ -34,8 +34,8 @@ import {
DbOperationEvent,
isDbAbortError,
PlaybackPositionService,
PlaylistDeleteActionService,
PlaylistRefreshService,
PlaylistsService,
RuntimeCapabilitiesService,
SortBy,
SortService,
@@ -87,7 +87,7 @@ export class RecentPlaylistsComponent {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly translate = inject(TranslateService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistsService = inject(PlaylistsService);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
@@ -253,9 +253,13 @@ export class RecentPlaylistsComponent {
this.setPendingDeletion(item._id, true);
try {
const deleted = this.isElectron
? await this.deletePlaylistInElectron(item)
: await this.deletePlaylistInBrowser(item);
const deleted = await this.playlistDeleteAction.deletePlaylist(
item,
{
onEvent: (event) =>
this.updateBusyOperation(item._id, event),
}
);
if (deleted) {
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: item._id })
@@ -276,30 +280,6 @@ export class RecentPlaylistsComponent {
}
}
private async deletePlaylistInElectron(item: PlaylistMeta) {
const operationId = item.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
return this.databaseService.deletePlaylist(
item._id,
operationId
? {
operationId,
onEvent: (event) =>
this.updateBusyOperation(item._id, event),
}
: undefined
);
}
private async deletePlaylistInBrowser(item: PlaylistMeta) {
const result = await firstValueFrom(
this.playlistsService.deletePlaylist(item._id)
);
return result.success;
}
/**
* Sends an IPC event with the playlist details to the main process to trigger the refresh operation
* @param item playlist to update
+1
View File
@@ -3,6 +3,7 @@ export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-delete-cleanup.token';
export * from './lib/playlist-delete-action.service';
export * from './lib/playlist-backup.service';
export * from './lib/playlist-refresh.service';
export * from './lib/playlists.service';
@@ -0,0 +1,101 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DatabaseService } from './database-electron.service';
import { PlaylistDeleteActionService } from './playlist-delete-action.service';
import { PlaylistsService } from './playlists.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
describe('PlaylistDeleteActionService', () => {
const playlist = {
_id: 'playlist-1',
title: 'Demo Playlist',
serverUrl: 'http://demo.example',
} as PlaylistMeta;
let databaseService: {
createOperationId: jest.Mock<string, [string]>;
deletePlaylist: jest.Mock<Promise<boolean>, unknown[]>;
};
let playlistsService: {
deletePlaylist: jest.Mock;
};
let runtime: {
isElectron: boolean;
};
beforeEach(() => {
databaseService = {
createOperationId: jest
.fn<string, [string]>()
.mockReturnValue('playlist-delete-1'),
deletePlaylist: jest.fn(async () => true),
};
playlistsService = {
deletePlaylist: jest.fn(() => of({ success: true })),
};
runtime = {
isElectron: false,
};
TestBed.configureTestingModule({
providers: [
PlaylistDeleteActionService,
{ provide: DatabaseService, useValue: databaseService },
{ provide: PlaylistsService, useValue: playlistsService },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
],
});
});
it('deletes browser playlists through PlaylistsService', async () => {
const service = TestBed.inject(PlaylistDeleteActionService);
await expect(service.deletePlaylist(playlist)).resolves.toBe(true);
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1'
);
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
it('deletes Electron Xtream playlists through DatabaseService with progress options', async () => {
runtime.isElectron = true;
const onEvent = jest.fn();
const service = TestBed.inject(PlaylistDeleteActionService);
await expect(
service.deletePlaylist(playlist, { onEvent })
).resolves.toBe(true);
expect(databaseService.createOperationId).toHaveBeenCalledWith(
'playlist-delete'
);
expect(databaseService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1',
{
operationId: 'playlist-delete-1',
onEvent,
}
);
expect(playlistsService.deletePlaylist).not.toHaveBeenCalled();
});
it('deletes Electron non-Xtream playlists without progress options', async () => {
runtime.isElectron = true;
const service = TestBed.inject(PlaylistDeleteActionService);
await expect(
service.deletePlaylist({
...playlist,
serverUrl: undefined,
} as PlaylistMeta)
).resolves.toBe(true);
expect(databaseService.createOperationId).not.toHaveBeenCalled();
expect(databaseService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1',
undefined
);
});
});
@@ -0,0 +1,58 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
DatabaseService,
type DbOperationEvent,
} from './database-electron.service';
import { PlaylistsService } from './playlists.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
export interface PlaylistDeleteActionOptions {
/**
* Receives Electron DB progress events only for Xtream-style playlists that
* have a server URL. PWA deletes and non-Xtream Electron deletes do not emit
* playlist delete progress events.
*/
readonly onEvent?: (event: DbOperationEvent) => void;
}
@Injectable({ providedIn: 'root' })
export class PlaylistDeleteActionService {
private readonly databaseService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
async deletePlaylist(
playlist: PlaylistMeta,
options: PlaylistDeleteActionOptions = {}
): Promise<boolean> {
if (this.runtime.isElectron) {
return this.deletePlaylistInElectron(playlist, options);
}
const result = await firstValueFrom(
this.playlistsService.deletePlaylist(playlist._id)
);
return result.success;
}
private deletePlaylistInElectron(
playlist: PlaylistMeta,
options: PlaylistDeleteActionOptions
): Promise<boolean> {
const operationId = playlist.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
return this.databaseService.deletePlaylist(
playlist._id,
operationId
? {
operationId,
onEvent: options.onEvent,
}
: undefined
);
}
}
@@ -18,7 +18,6 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import {
EmptyStateComponent,
PlaylistInfoComponent,
@@ -31,8 +30,7 @@ import {
import { DialogService } from '@iptvnator/ui/components';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
DatabaseService,
PlaylistsService,
PlaylistDeleteActionService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
@@ -416,9 +414,9 @@ function isXtreamAccountPlaylist(
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
private readonly databaseService = inject(DatabaseService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
@@ -431,7 +429,6 @@ export class WorkspaceDashboardRailsComponent {
);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly epgService = inject(EpgService);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
@@ -781,9 +778,8 @@ export class WorkspaceDashboardRailsComponent {
}
private async removePlaylist(playlist: PlaylistMeta): Promise<void> {
const deleted = this.runtime.isElectron
? await this.deletePlaylistInElectron(playlist)
: await this.deletePlaylistInBrowser(playlist);
const deleted =
await this.playlistDeleteAction.deletePlaylist(playlist);
if (!deleted) {
return;
@@ -799,28 +795,6 @@ export class WorkspaceDashboardRailsComponent {
);
}
private async deletePlaylistInElectron(
playlist: PlaylistMeta
): Promise<boolean> {
const operationId = playlist.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
return this.databaseService.deletePlaylist(
playlist._id,
operationId ? { operationId } : undefined
);
}
private async deletePlaylistInBrowser(
playlist: PlaylistMeta
): Promise<boolean> {
const result = await firstValueFrom(
this.playlistsService.deletePlaylist(playlist._id)
);
return result.success;
}
private t(key: string): string {
return this.translate.instant(key);
}