diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 589d2819b..e2fd39d16 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -10,6 +10,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; +import { DialogService } from 'components'; import { COLLECTION_VIEW_STATE_KEY, CollectionScope, @@ -117,6 +118,7 @@ describe('UnifiedCollectionPageComponent', () => { const playlists = signal([]); const favoritesData = { getFavorites: jest.fn().mockResolvedValue([]), + clearFavorites: jest.fn().mockResolvedValue(undefined), removeFavorite: jest.fn(), reorder: jest.fn(), }; @@ -125,6 +127,9 @@ describe('UnifiedCollectionPageComponent', () => { removeRecentItem: jest.fn(), clearRecentItems: jest.fn(), }; + const dialogService = { + openConfirmDialog: jest.fn(), + }; const router = { navigate: jest.fn().mockResolvedValue(true), url: '/workspace/global-favorites', @@ -252,6 +257,10 @@ describe('UnifiedCollectionPageComponent', () => { provide: UnifiedRecentDataService, useValue: recentData, }, + { + provide: DialogService, + useValue: dialogService, + }, ], }) .overrideComponent(UnifiedCollectionPageComponent, { @@ -414,6 +423,84 @@ describe('UnifiedCollectionPageComponent', () => { ); }); + it('clears current favorites through the bulk clear service path', async () => { + const liveItems = [ + { + uid: 'm3u::playlist-1::one', + name: 'Favorite One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/one.m3u8', + }, + { + uid: 'm3u::playlist-1::two', + name: 'Favorite Two', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/two.m3u8', + }, + ] satisfies UnifiedCollectionItem[]; + favoritesData.getFavorites.mockResolvedValueOnce(liveItems); + + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.clearAllCurrent(); + const [dialogConfig] = dialogService.openConfirmDialog.mock.calls.at( + -1 + ) ?? [null]; + + expect(dialogConfig).toBeTruthy(); + + await dialogConfig.onConfirm(); + + expect(favoritesData.clearFavorites).toHaveBeenCalledTimes(1); + expect(favoritesData.clearFavorites).toHaveBeenCalledWith(liveItems); + expect(favoritesData.removeFavorite).not.toHaveBeenCalled(); + }); + + it('reloads favorites when bulk clear persistence fails', async () => { + const liveItems = [ + { + uid: 'm3u::playlist-1::one', + name: 'Favorite One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/one.m3u8', + }, + ] satisfies UnifiedCollectionItem[]; + favoritesData.getFavorites + .mockResolvedValueOnce(liveItems) + .mockResolvedValueOnce(liveItems); + favoritesData.clearFavorites.mockRejectedValueOnce( + new Error('clear failed') + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.clearAllCurrent(); + const [dialogConfig] = dialogService.openConfirmDialog.mock.calls.at( + -1 + ) ?? [null]; + + await dialogConfig.onConfirm(); + + expect(favoritesData.getFavorites).toHaveBeenCalledTimes(2); + expect(favoritesData.getFavorites).toHaveBeenLastCalledWith( + 'all', + undefined, + undefined + ); + expect(fixture.componentInstance.allItems()).toEqual(liveItems); + }); + it('opens inline detail from history state when a detail template is projected', async () => { const item: UnifiedCollectionItem = { uid: 'xtream::xtream-1::77', diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 3f50e0ca5..23d9b658e 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -461,7 +461,12 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { this.dialogService.openConfirmDialog({ title: this.translate.instant(titleKey, { type }), message: this.translate.instant(messageKey, { type }), - onConfirm: () => { + onConfirm: async () => { + if (isFavorites) { + await this.clearCurrentFavorites(itemsToRemove); + return; + } + const removedType = this.selectedContentType(); this.allItems.update((items) => items.filter((item) => item.contentType !== removedType) @@ -472,9 +477,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { } void Promise.all( itemsToRemove.map((item) => - isFavorites - ? this.favoritesData.removeFavorite(item) - : this.recentData.removeRecentItem(item) + this.recentData.removeRecentItem(item) ) ); }, @@ -509,6 +512,25 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { }); } + private async clearCurrentFavorites( + itemsToRemove: UnifiedCollectionItem[] + ): Promise { + const removedType = this.selectedContentType(); + this.allItems.update((items) => + items.filter((item) => item.contentType !== removedType) + ); + const remaining = this.availableTypes(); + if (remaining.length > 0) { + this.selectedContentType.set(remaining[0]); + } + + try { + await this.favoritesData.clearFavorites(itemsToRemove); + } catch { + await this.reloadCurrentCollection(); + } + } + private async loadData(params: { mode: 'favorites' | 'recent'; portalType?: string; @@ -556,6 +578,15 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { } } + private async reloadCurrentCollection(): Promise { + await this.loadData({ + mode: this.mode(), + portalType: this.portalType(), + playlistId: this.playlistId(), + scope: this.effectiveScope(), + }); + } + private autoSelectContentType(): void { if (this.selectedDetailItem()) { return; diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts index 73210e424..f43b14ac2 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts @@ -14,6 +14,10 @@ import { UnifiedFavoritesDataService } from './unified-favorites-data.service'; describe('UnifiedFavoritesDataService', () => { let service: UnifiedFavoritesDataService; + let electronApi: { + dbGetAllGlobalFavorites: jest.Mock; + dbRemoveFavorite: jest.Mock; + }; let databaseService: { getAllGlobalFavorites: jest.Mock; getFavorites: jest.Mock; @@ -79,10 +83,12 @@ describe('UnifiedFavoritesDataService', () => { ]; beforeEach(() => { + electronApi = { + dbGetAllGlobalFavorites: jest.fn(), + dbRemoveFavorite: jest.fn().mockResolvedValue(undefined), + }; Object.defineProperty(window, 'electron', { - value: { - dbGetAllGlobalFavorites: jest.fn(), - } as Window['electron'], + value: electronApi as Window['electron'], configurable: true, }); @@ -310,4 +316,127 @@ describe('UnifiedFavoritesDataService', () => { [stalkerFavorites[1], stalkerFavorites[0]] ); }); + + it('clears M3U favorites once per playlist with the remaining favorites preserved', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'm3u-1', + favorites: [ + 'https://example.com/2.m3u8', + 'channel-1', + 'https://example.com/3.m3u8', + ], + } satisfies Partial) + ); + + await service.clearFavorites([ + { + uid: 'm3u::m3u-1::https://example.com/2.m3u8', + name: 'Channel Two', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + playlistName: 'M3U List', + streamUrl: 'https://example.com/2.m3u8', + channelId: 'channel-2', + }, + { + uid: 'm3u::m3u-1::channel-1', + name: 'Channel One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + playlistName: 'M3U List', + streamUrl: 'https://example.com/1.m3u8', + channelId: 'channel-1', + }, + ] satisfies UnifiedCollectionItem[]); + + expect(playlistsService.setFavorites).toHaveBeenCalledTimes(1); + expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [ + 'https://example.com/3.m3u8', + ]); + }); + + it('clears Stalker favorites once per playlist with the remaining favorites preserved', async () => { + const remainingFavorite: StalkerPortalItem = { + id: '303', + title: 'Stalker Three', + category_id: 'itv', + cmd: 'ffmpeg http://stalker/303', + logo: 'three.png', + added_at: '2026-03-26T12:00:00.000Z', + }; + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + macAddress: '00:11:22:33:44:55', + favorites: [...stalkerFavorites, remainingFavorite], + } satisfies Partial) + ); + + await service.clearFavorites([ + { + uid: 'stalker::stalker-1::101', + name: 'Stalker One', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker List', + stalkerId: '101', + }, + { + uid: 'stalker::stalker-1::202', + name: 'Stalker Two', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker List', + stalkerId: '202', + }, + ] satisfies UnifiedCollectionItem[]); + + expect(playlistsService.setPortalFavorites).toHaveBeenCalledTimes(1); + expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith( + 'stalker-1', + [remainingFavorite] + ); + }); + + it('clears Xtream favorites through the bulk removal path', async () => { + await service.clearFavorites([ + { + uid: 'xtream::xtream-1::101', + name: 'Xtream One', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 101, + contentId: 10, + }, + { + uid: 'xtream::xtream-1::102', + name: 'Xtream Two', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 102, + contentId: 11, + }, + ] satisfies UnifiedCollectionItem[]); + + expect(electronApi.dbRemoveFavorite).toHaveBeenCalledTimes(2); + expect(electronApi.dbRemoveFavorite).toHaveBeenNthCalledWith( + 1, + 10, + 'xtream-1' + ); + expect(electronApi.dbRemoveFavorite).toHaveBeenNthCalledWith( + 2, + 11, + 'xtream-1' + ); + }); }); diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts index 445bc293d..74e439dba 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts @@ -82,6 +82,24 @@ export class UnifiedFavoritesDataService { } } + async clearFavorites(items: UnifiedCollectionItem[]): Promise { + if (items.length === 0) { + return; + } + + await Promise.all([ + this.clearM3uFavorites( + items.filter((item) => item.sourceType === 'm3u') + ), + this.clearXtreamFavorites( + items.filter((item) => item.sourceType === 'xtream') + ), + this.clearStalkerFavorites( + items.filter((item) => item.sourceType === 'stalker') + ), + ]); + } + async reorder( items: UnifiedCollectionItem[], options?: { @@ -172,6 +190,101 @@ export class UnifiedFavoritesDataService { return this.applyOrder([...m3u, ...xtream, ...stalker], order); } + private async clearM3uFavorites( + items: UnifiedCollectionItem[] + ): Promise { + const groupedItems = this.groupItemsByPlaylist(items); + + await Promise.all( + Array.from(groupedItems.entries()).map( + async ([playlistId, playlistItems]) => { + const playlist = (await firstValueFrom( + this.playlistsService.getPlaylistById(playlistId) + )) as Playlist | undefined; + const targetIds = new Set(); + playlistItems.forEach((item) => { + [item.streamUrl, item.channelId].forEach((value) => { + const normalized = value?.trim(); + if (normalized) { + targetIds.add(normalized); + } + }); + }); + const currentFavorites = Array.isArray(playlist?.favorites) + ? playlist.favorites.filter( + (favorite): favorite is string => + typeof favorite === 'string' + ) + : []; + const nextFavorites = currentFavorites.filter( + (favorite) => !targetIds.has(favorite.trim()) + ); + + await firstValueFrom( + this.playlistsService.setFavorites( + playlistId, + nextFavorites + ) + ); + } + ) + ); + } + + private async clearXtreamFavorites( + items: UnifiedCollectionItem[] + ): Promise { + if (!window.electron) { + return; + } + + await Promise.all( + items + .filter((item) => item.contentId != null) + .map((item) => + window.electron!.dbRemoveFavorite( + item.contentId!, + item.playlistId + ) + ) + ); + } + + private async clearStalkerFavorites( + items: UnifiedCollectionItem[] + ): Promise { + const groupedItems = this.groupItemsByPlaylist(items); + + await Promise.all( + Array.from(groupedItems.entries()).map( + async ([playlistId, playlistItems]) => { + const playlist = (await firstValueFrom( + this.playlistsService.getPlaylistById(playlistId) + )) as Playlist | undefined; + const targetIds = new Set( + playlistItems.map((item) => + this.getStalkerFavoriteId(item) + ) + ); + const currentFavorites = Array.isArray(playlist?.favorites) + ? playlist.favorites.filter(isStalkerItem) + : []; + const nextFavorites = currentFavorites.filter( + (favorite) => + !targetIds.has(this.getStalkerFavoriteId(favorite)) + ); + + await firstValueFrom( + this.playlistsService.setPortalFavorites( + playlistId, + nextFavorites + ) + ); + } + ) + ); + } + private async getPlaylistFavorites( playlistId: string, portalType?: string @@ -407,6 +520,21 @@ export class UnifiedFavoritesDataService { })); } + private groupItemsByPlaylist( + items: UnifiedCollectionItem[] + ): Map { + return items.reduce((groups, item) => { + const group = groups.get(item.playlistId); + if (group) { + group.push(item); + } else { + groups.set(item.playlistId, [item]); + } + + return groups; + }, new Map()); + } + private getStalkerFavoriteId( favorite: Pick | StalkerPortalItem ): string { diff --git a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts index 98da3beb6..1bc2b286a 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts @@ -7,6 +7,7 @@ import { signal, } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { GlobalFavoritesListComponent, UnifiedLiveTabComponent, @@ -23,7 +24,9 @@ import { SettingsStore } from 'services'; import { Channel, EpgItem, EpgProgram } from 'shared-interfaces'; import { EpgViewComponent } from 'shared-portals'; import { + DEFAULT_FAVORITES_CHANNEL_SORT_MODE, PORTAL_PLAYER, + FavoritesChannelSortMode, StreamResolverService, UnifiedCollectionItem, UnifiedFavoriteChannel, @@ -52,6 +55,9 @@ class StubGlobalFavoritesListComponent { readonly activeUid = input(null); readonly searchTermInput = input(''); readonly draggable = input(true); + readonly sortMode = input( + DEFAULT_FAVORITES_CHANNEL_SORT_MODE + ); readonly channelSelected = output(); readonly channelsReordered = output(); @@ -154,7 +160,7 @@ describe('UnifiedLiveTabComponent', () => { }; await TestBed.configureTestingModule({ - imports: [UnifiedLiveTabComponent], + imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent], providers: [ { provide: StreamResolverService, useValue: streamResolver }, { provide: UnifiedRecentDataService, useValue: recentData },