diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index 27e452eef..14ad7db2a 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -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; 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, diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 22d9a2404..50fe68916 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -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 | null = null; @@ -315,9 +311,8 @@ export class PlaylistSwitcherComponent { private async removePlaylistConfirmed( playlist: PlaylistMeta ): Promise { - 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 { - 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 { - const result = await firstValueFrom( - this.playlistsService.deletePlaylist(playlist._id) - ); - return result.success; - } - getPlaylistIcon(playlist: PlaylistMeta): string { if (playlist.macAddress) { return 'dashboard'; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 75b6b521c..08a23ea86 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -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(); - 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; } ) => { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index f147a8ef5..aa74c8e6b 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -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 diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 16fd01c4d..8270af674 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/playlist-delete-action.service.spec.ts b/libs/services/src/lib/playlist-delete-action.service.spec.ts new file mode 100644 index 000000000..aa6258c5a --- /dev/null +++ b/libs/services/src/lib/playlist-delete-action.service.spec.ts @@ -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; + deletePlaylist: jest.Mock, unknown[]>; + }; + let playlistsService: { + deletePlaylist: jest.Mock; + }; + let runtime: { + isElectron: boolean; + }; + + beforeEach(() => { + databaseService = { + createOperationId: jest + .fn() + .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 + ); + }); +}); diff --git a/libs/services/src/lib/playlist-delete-action.service.ts b/libs/services/src/lib/playlist-delete-action.service.ts new file mode 100644 index 000000000..7487341be --- /dev/null +++ b/libs/services/src/lib/playlist-delete-action.service.ts @@ -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 { + 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 { + const operationId = playlist.serverUrl + ? this.databaseService.createOperationId('playlist-delete') + : undefined; + + return this.databaseService.deletePlaylist( + playlist._id, + operationId + ? { + operationId, + onEvent: options.onEvent, + } + : undefined + ); + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index d2bb748ad..28d42cd6e 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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 { - 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 { - 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 { - const result = await firstValueFrom( - this.playlistsService.deletePlaylist(playlist._id) - ); - return result.success; - } - private t(key: string): string { return this.translate.instant(key); }