feat: implement bulk clear favorites functionality with confirmation dialog

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-19 23:38:32 +02:00
1 parent 973423a2b5
commit 3f79b01a9d
5 files changed
+389 -8

No files matched your search

@@ -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<PlaylistMeta[]>([]);
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',
@@ -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<void> {
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<void> {
await this.loadData({
mode: this.mode(),
portalType: this.portalType(),
playlistId: this.playlistId(),
scope: this.effectiveScope(),
});
}
private autoSelectContentType(): void {
if (this.selectedDetailItem()) {
return;
@@ -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<Playlist>)
);
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<Playlist>)
);
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'
);
});
});
@@ -82,6 +82,24 @@ export class UnifiedFavoritesDataService {
}
}
async clearFavorites(items: UnifiedCollectionItem[]): Promise<void> {
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<void> {
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<string>();
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<void> {
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<void> {
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<string, UnifiedCollectionItem[]> {
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<string, UnifiedCollectionItem[]>());
}
private getStalkerFavoriteId(
favorite: Pick<UnifiedCollectionItem, 'stalkerId' | 'uid'> | StalkerPortalItem
): string {
@@ -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<string | null>(null);
readonly searchTermInput = input('');
readonly draggable = input(true);
readonly sortMode = input<FavoritesChannelSortMode>(
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
@@ -154,7 +160,7 @@ describe('UnifiedLiveTabComponent', () => {
};
await TestBed.configureTestingModule({
imports: [UnifiedLiveTabComponent],
imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent],
providers: [
{ provide: StreamResolverService, useValue: streamResolver },
{ provide: UnifiedRecentDataService, useValue: recentData },