mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat: implement bulk clear favorites functionality with confirmation dialog
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
1 parent
973423a2b5
commit
3f79b01a9d
5 files changed
+389
-8
No files matched your search
+87
@@ -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',
|
||||
|
||||
+35
-4
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user