fix: restore recent live favorite toggles

Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4gray committed 2026-04-30 21:35:39 +02:00
1 parent 911ec1369c
commit 290843ef3f
12 files changed
+534 -41

No files matched your search

+17 -12
View File
@@ -89,7 +89,7 @@ test.describe('Electron Recently Viewed', () => {
}
});
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists removals after restart', async ({
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists favorites after restart', async ({
dataDir,
}) => {
const playlistTitle = 'm3u-recent-source.m3u';
@@ -129,10 +129,15 @@ test.describe('Electron Recently Viewed', () => {
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel Two', 'Recent Channel One']);
await removeLiveRecentItem(app.mainWindow, 'Recent Channel Two');
await toggleFavoriteForChannel(app.mainWindow, 'Recent Channel Two');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel One']);
.toEqual(['Recent Channel Two', 'Recent Channel One']);
await openPlaylistFavorites(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'Recent Channel Two').first()
).toBeVisible({ timeout: 20000 });
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
@@ -147,7 +152,12 @@ test.describe('Electron Recently Viewed', () => {
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel One']);
.toEqual(['Recent Channel Two', 'Recent Channel One']);
await openPlaylistFavorites(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'Recent Channel Two').first()
).toBeVisible({ timeout: 20000 });
} finally {
await closeElectronApp(app);
}
@@ -643,14 +653,6 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
await episodeCard.click();
}
async function removeLiveRecentItem(page: Page, title: string): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
@@ -660,6 +662,9 @@ async function toggleFavoriteForChannel(
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
'star'
);
}
async function visibleLiveTitles(page: Page): Promise<string[]> {
@@ -40,7 +40,7 @@
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[showFavoriteButton]="true"
[isFavorite]="true"
[isFavorite]="isChannelFavorite(ch)"
[showProgramInfoButton]="true"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
@@ -0,0 +1,93 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
import { GlobalFavoritesListComponent } from './global-favorites-list.component';
describe('GlobalFavoritesListComponent', () => {
let fixture: ComponentFixture<GlobalFavoritesListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
GlobalFavoritesListComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: { open: jest.fn() },
},
],
}).compileComponents();
fixture = TestBed.createComponent(GlobalFavoritesListComponent);
});
it('renders filled favorite stars in favorites mode', () => {
fixture.componentRef.setInput('channels', [buildChannel('b', 'Beta')]);
fixture.detectChanges();
const favoriteIcon = fixture.nativeElement.querySelector(
'.favorite-button mat-icon'
);
expect(favoriteIcon?.textContent?.trim()).toBe('star');
expect(
fixture.nativeElement.querySelectorAll('.favorite-button')
).toHaveLength(1);
});
it('renders favorite state from the supplied favorite ids in recent mode', () => {
fixture.componentRef.setInput('mode', 'recent');
fixture.componentRef.setInput(
'favoriteUids',
new Set<string>(['b'])
);
fixture.componentRef.setInput('channels', [
buildChannel('a', 'Alpha'),
buildChannel('b', 'Beta'),
]);
fixture.detectChanges();
const icons = Array.from(
fixture.nativeElement.querySelectorAll('.favorite-button mat-icon'),
(element: Element) => element.textContent?.trim()
);
expect(icons).toEqual(['star_outline', 'star']);
});
it('preserves incoming recent order when a favorites sort mode is set', () => {
fixture.componentRef.setInput('mode', 'recent');
fixture.componentRef.setInput('sortMode', 'name-asc');
fixture.componentRef.setInput('channels', [
buildChannel('z', 'Zulu'),
buildChannel('a', 'Alpha'),
]);
fixture.detectChanges();
const names = Array.from(
fixture.nativeElement.querySelectorAll('.channel-name'),
(element: Element) => element.textContent?.trim()
);
expect(names).toEqual(['Zulu', 'Alpha']);
});
});
function buildChannel(uid: string, name: string): UnifiedFavoriteChannel {
return {
uid,
name,
logo: null,
sourceType: 'm3u',
playlistId: 'playlist-1',
playlistName: 'Playlist One',
streamUrl: `https://example.com/${uid}.m3u8`,
addedAt: '2026-04-30T12:00:00.000Z',
position: 0,
};
}
@@ -26,6 +26,8 @@ export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
progressPercentage: number;
}
export type GlobalFavoritesListMode = 'favorites' | 'recent';
@Component({
selector: 'app-global-favorites-list',
templateUrl: './global-favorites-list.component.html',
@@ -40,6 +42,8 @@ export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
})
export class GlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly mode = input<GlobalFavoritesListMode>('favorites');
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input<number>(0);
readonly activeUid = input<string | null>(null);
@@ -55,7 +59,11 @@ export class GlobalFavoritesListComponent {
readonly isCustomSort = computed(() => this.sortMode() === 'custom');
readonly canDragDrop = computed(
() => this.draggable() && this.isCustomSort() && !this.hasSearchTerm()
() =>
this.mode() === 'favorites' &&
this.draggable() &&
this.isCustomSort() &&
!this.hasSearchTerm()
);
readonly hasSearchTerm = computed(
@@ -72,10 +80,13 @@ export class GlobalFavoritesListComponent {
? channels.filter((ch) => ch.name.toLowerCase().includes(term))
: channels;
const sorted = sortFavoriteChannelItems(filtered, this.sortMode(), {
const sorted =
this.mode() === 'favorites'
? sortFavoriteChannelItems(filtered, this.sortMode(), {
getName: (ch) => ch.name,
getAddedAt: (ch) => ch.addedAt,
});
})
: filtered;
return sorted.map((ch) => {
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
@@ -98,6 +109,12 @@ export class GlobalFavoritesListComponent {
this.favoriteToggled.emit(channel);
}
isChannelFavorite(channel: UnifiedFavoriteChannel): boolean {
return (
this.mode() === 'favorites' || this.favoriteUids().has(channel.uid)
);
}
onDrop(event: CdkDragDrop<EnrichedUnifiedFavorite[]>): void {
if (!this.canDragDrop()) {
return;
@@ -172,8 +172,10 @@
[mode]="mode()"
[searchTerm]="workspaceSearchTerm()"
[autoOpenItem]="pendingAutoOpenLiveItem()"
[favoriteUids]="favoriteUidSet()"
[sortMode]="favSortMode()"
(removeItem)="onRemoveItem($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(reorderItems)="onReorder($event)"
(itemPlayed)="onItemPlayed($event)"
(autoOpenHandled)="onLiveAutoOpenHandled()"
@@ -7,6 +7,7 @@ import {
ViewChild,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
@@ -40,9 +41,11 @@ class StubUnifiedLiveTabComponent {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly autoOpenItem = input<unknown>(null);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly sortMode = input<FavoritesChannelSortMode>('custom');
readonly removeItem = output<UnifiedCollectionItem>();
readonly favoriteToggled = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
@@ -120,10 +123,11 @@ describe('UnifiedCollectionPageComponent', () => {
const playlistsLoaded = signal(false);
const playlists = signal<PlaylistMeta[]>([]);
const favoritesData = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getFavorites: jest.fn().mockResolvedValue([]),
clearFavorites: jest.fn().mockResolvedValue(undefined),
removeFavorite: jest.fn(),
reorder: jest.fn(),
removeFavorite: jest.fn().mockResolvedValue(undefined),
reorder: jest.fn().mockResolvedValue(undefined),
};
const recentData = {
getRecentItems: jest.fn().mockResolvedValue([]),
@@ -434,6 +438,55 @@ describe('UnifiedCollectionPageComponent', () => {
);
});
it('passes favorite state to recent live rows and toggles favorites without removing the row', async () => {
const recentItem = {
uid: 'm3u::playlist-1::https://example.com/one.m3u8',
name: 'Recent One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'playlist-1',
playlistName: 'Playlist One',
streamUrl: 'https://example.com/one.m3u8',
} satisfies UnifiedCollectionItem;
recentData.getRecentItems.mockResolvedValueOnce([recentItem]);
favoritesData.getFavorites.mockResolvedValueOnce([recentItem]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.componentInstance.isLoading.set(false);
fixture.componentInstance.selectedContentType.set('live');
fixture.componentInstance.allItems.set([recentItem]);
fixture.componentInstance.favoriteUidSet.set(
new Set<string>([recentItem.uid])
);
fixture.detectChanges();
const liveTab = fixture.debugElement.query(
By.directive(StubUnifiedLiveTabComponent)
).componentInstance as StubUnifiedLiveTabComponent;
expect(liveTab.favoriteUids().has(recentItem.uid)).toBe(true);
liveTab.favoriteToggled.emit(recentItem);
await fixture.whenStable();
expect(favoritesData.removeFavorite).toHaveBeenCalledWith(recentItem);
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
.toBe(false);
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
liveTab.favoriteToggled.emit(recentItem);
await fixture.whenStable();
expect(favoritesData.addFavorite).toHaveBeenCalledWith(recentItem);
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
.toBe(true);
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
});
it('clears current favorites through the bulk clear service path', async () => {
const liveItems = [
{
@@ -132,6 +132,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
readonly isLoading = signal(true);
readonly allItems = signal<UnifiedCollectionItem[]>([]);
readonly favoriteUidSet = signal<ReadonlySet<string>>(new Set<string>());
readonly selectedContentType = signal<CollectionContentType>(
this.historyCollectionViewState()?.selectedContentType ?? 'live'
);
@@ -487,6 +488,11 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
async onRemoveItem(item: UnifiedCollectionItem): Promise<void> {
if (this.mode() === 'favorites') {
await this.favoritesData.removeFavorite(item);
this.favoriteUidSet.update((favoriteUids) => {
const nextFavoriteUids = new Set(favoriteUids);
nextFavoriteUids.delete(item.uid);
return nextFavoriteUids;
});
} else {
await this.recentData.removeRecentItem(item);
}
@@ -495,6 +501,24 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
);
}
async onFavoriteToggled(item: UnifiedCollectionItem): Promise<void> {
if (this.mode() !== 'recent') {
return;
}
const nextFavoriteUids = new Set(this.favoriteUidSet());
if (nextFavoriteUids.has(item.uid)) {
await this.favoritesData.removeFavorite(item);
nextFavoriteUids.delete(item.uid);
} else {
await this.favoritesData.addFavorite(item);
nextFavoriteUids.add(item.uid);
}
this.favoriteUidSet.set(nextFavoriteUids);
}
clearAllCurrent(): void {
const itemsToRemove = this.currentTypeItems();
if (itemsToRemove.length === 0) {
@@ -612,10 +636,15 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
params.playlistId,
params.portalType
);
const favoriteUids =
params.mode === 'favorites'
? new Set(items.map((item) => item.uid))
: await this.loadFavoriteUidSet(params);
if (requestId !== this.loadRequestId) {
return;
}
this.allItems.set(items);
this.favoriteUidSet.set(favoriteUids);
this.autoSelectContentType();
if (
this.pendingAutoOpenLiveItem() &&
@@ -644,6 +673,23 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
});
}
private async loadFavoriteUidSet(params: {
portalType?: string;
playlistId?: string;
scope: CollectionScope;
}): Promise<ReadonlySet<string>> {
try {
const favorites = await this.favoritesData.getFavorites(
params.scope,
params.playlistId,
params.portalType
);
return new Set(favorites.map((item) => item.uid));
} catch {
return new Set<string>();
}
}
private autoSelectContentType(): void {
if (this.selectedDetailItem()) {
return;
@@ -8,6 +8,8 @@
>
<app-global-favorites-list
[channels]="channelsForList()"
[mode]="mode()"
[favoriteUids]="favoriteUids()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
@@ -79,11 +79,13 @@ export class UnifiedLiveTabComponent {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly autoOpenItem = input<OpenLiveCollectionItemState | null>(null);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly sortMode = input<FavoritesChannelSortMode>(
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
);
readonly removeItem = output<UnifiedCollectionItem>();
readonly favoriteToggled = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
@@ -268,7 +270,11 @@ export class UnifiedLiveTabComponent {
(candidate) => candidate.uid === channel.uid
);
if (item) {
if (this.mode() === 'favorites') {
this.removeItem.emit(item);
} else {
this.favoriteToggled.emit(item);
}
}
}
@@ -15,14 +15,21 @@ import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
describe('UnifiedFavoritesDataService', () => {
let service: UnifiedFavoritesDataService;
let electronApi: {
dbAddFavorite: jest.Mock;
dbGetAllGlobalFavorites: jest.Mock;
dbRemoveFavorite: jest.Mock;
};
let databaseService: {
getAllGlobalFavorites: jest.Mock;
getContentByXtreamId: jest.Mock;
getFavorites: jest.Mock;
};
let store: {
dispatch: jest.Mock;
select: jest.Mock;
};
let playlistsService: {
addPortalFavorite: jest.Mock;
getPlaylistById: jest.Mock;
setFavorites: jest.Mock;
setPortalFavorites: jest.Mock;
@@ -84,6 +91,7 @@ describe('UnifiedFavoritesDataService', () => {
beforeEach(() => {
electronApi = {
dbAddFavorite: jest.fn().mockResolvedValue({ success: true }),
dbGetAllGlobalFavorites: jest.fn(),
dbRemoveFavorite: jest.fn().mockResolvedValue(undefined),
};
@@ -93,21 +101,18 @@ describe('UnifiedFavoritesDataService', () => {
});
playlistsService = {
addPortalFavorite: jest.fn().mockReturnValue(of({})),
getPlaylistById: jest.fn(),
setFavorites: jest.fn().mockReturnValue(of({})),
setPortalFavorites: jest.fn().mockReturnValue(of({})),
};
databaseService = {
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getFavorites: jest.fn().mockResolvedValue([]),
};
TestBed.configureTestingModule({
providers: [
UnifiedFavoritesDataService,
{
provide: Store,
useValue: {
store = {
dispatch: jest.fn(),
select: jest.fn(() =>
of([
{
@@ -126,7 +131,14 @@ describe('UnifiedFavoritesDataService', () => {
},
] satisfies PlaylistMeta[])
),
},
};
TestBed.configureTestingModule({
providers: [
UnifiedFavoritesDataService,
{
provide: Store,
useValue: store,
},
{
provide: DatabaseService,
@@ -274,6 +286,111 @@ describe('UnifiedFavoritesDataService', () => {
]);
});
it('adds M3U favorites through setFavorites without duplicating existing entries', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'm3u-1',
favorites: ['https://example.com/existing.m3u8'],
} satisfies Partial<Playlist>)
);
await service.addFavorite({
uid: 'm3u::m3u-1::https://example.com/new.m3u8',
name: 'New Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/new.m3u8',
channelId: 'new-channel',
} satisfies UnifiedCollectionItem);
expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [
'https://example.com/existing.m3u8',
'https://example.com/new.m3u8',
]);
expect(store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: '[Playlists] Update Playlist Meta',
playlist: expect.objectContaining({
_id: 'm3u-1',
favorites: [
'https://example.com/existing.m3u8',
'https://example.com/new.m3u8',
],
}),
})
);
await service.addFavorite({
uid: 'm3u::m3u-1::https://example.com/existing.m3u8',
name: 'Existing Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/existing.m3u8',
} satisfies UnifiedCollectionItem);
expect(playlistsService.setFavorites).toHaveBeenCalledTimes(1);
});
it('adds Xtream favorites after resolving the content id', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
id: 42,
});
await service.addFavorite({
uid: 'xtream::xtream-1::101',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
logo: 'live.png',
xtreamId: 101,
} satisfies UnifiedCollectionItem);
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
101,
'xtream-1',
'live'
);
expect(electronApi.dbAddFavorite).toHaveBeenCalledWith(
42,
'xtream-1',
'live.png'
);
});
it('adds Stalker favorites through portal favorites', async () => {
await service.addFavorite({
uid: 'stalker::stalker-1::101',
name: 'Stalker One',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker List',
logo: 'one.png',
stalkerId: '101',
stalkerCmd: 'ffmpeg http://stalker/101',
categoryId: 'itv',
} satisfies UnifiedCollectionItem);
expect(playlistsService.addPortalFavorite).toHaveBeenCalledWith(
'stalker-1',
expect.objectContaining({
id: '101',
title: 'Stalker One',
name: 'Stalker One',
o_name: 'Stalker One',
category_id: 'itv',
cmd: 'ffmpeg http://stalker/101',
logo: 'one.png',
})
);
});
it('persists Stalker playlist reorders through setPortalFavorites', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
@@ -32,6 +32,11 @@ const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
};
type StalkerPortalFavoriteItem = StalkerPortalItem & {
category_id?: string;
raw?: string;
[key: string]: unknown;
};
@Injectable({ providedIn: 'root' })
export class UnifiedFavoritesDataService {
@@ -51,6 +56,20 @@ export class UnifiedFavoritesDataService {
return this.getAllFavorites();
}
async addFavorite(item: UnifiedCollectionItem): Promise<void> {
switch (item.sourceType) {
case 'm3u':
await this.addM3uFavorite(item);
break;
case 'xtream':
await this.addXtreamFavorite(item);
break;
case 'stalker':
await this.addStalkerFavorite(item);
break;
}
}
async removeFavorite(item: UnifiedCollectionItem): Promise<void> {
switch (item.sourceType) {
case 'm3u': {
@@ -63,26 +82,120 @@ export class UnifiedFavoritesDataService {
favoriteId !== item.streamUrl &&
favoriteId !== item.channelId
);
await firstValueFrom(
this.playlistsService.setFavorites(item.playlistId, filtered)
);
await this.setM3uFavorites(item.playlistId, filtered);
break;
}
case 'xtream':
if (window.electron && item.contentId != null) {
await window.electron.dbRemoveFavorite(item.contentId, item.playlistId);
await window.electron.dbRemoveFavorite(
item.contentId,
item.playlistId
);
}
break;
case 'stalker': {
const sourceItemId = item.uid.split('::')[2];
await firstValueFrom(
this.playlistsService.removeFromPortalFavorites(item.playlistId, sourceItemId)
this.playlistsService.removeFromPortalFavorites(
item.playlistId,
sourceItemId
)
);
break;
}
}
}
private async addM3uFavorite(item: UnifiedCollectionItem): Promise<void> {
const favoriteId = item.streamUrl ?? item.channelId;
if (!favoriteId) {
return;
}
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlistId)
) as Playlist | undefined;
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(
(favorite): favorite is string =>
typeof favorite === 'string'
)
: [];
if (currentFavorites.includes(favoriteId)) {
return;
}
await this.setM3uFavorites(item.playlistId, [
...currentFavorites,
favoriteId,
]);
}
private async addXtreamFavorite(item: UnifiedCollectionItem): Promise<void> {
if (!window.electron) {
return;
}
const contentId =
item.contentId ??
(item.xtreamId != null
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
: null);
if (contentId == null) {
return;
}
await window.electron.dbAddFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
);
}
private async addStalkerFavorite(
item: UnifiedCollectionItem
): Promise<void> {
const stalkerItem: StalkerPortalItem =
(item.stalkerItem as StalkerPortalItem | undefined) ?? {};
const favorite: StalkerPortalFavoriteItem = {
...stalkerItem,
id:
item.stalkerId ??
extractStalkerItemId(stalkerItem) ??
item.uid.split('::')[2],
cmd: item.stalkerCmd ?? stalkerItem.cmd,
title: item.name,
name: stalkerItem.name ?? item.name,
o_name: stalkerItem.o_name ?? item.name,
category_id: String(
item.categoryId ?? stalkerItem.category_id ?? 'itv'
),
cover:
stalkerItem.cover ??
item.logo ??
item.posterUrl ??
undefined,
logo:
stalkerItem.logo ??
item.logo ??
item.posterUrl ??
undefined,
added_at: new Date().toISOString(),
};
await firstValueFrom(
this.playlistsService.addPortalFavorite(item.playlistId, favorite)
);
}
async clearFavorites(items: UnifiedCollectionItem[]): Promise<void> {
if (items.length === 0) {
return;
@@ -114,13 +227,11 @@ export class UnifiedFavoritesDataService {
options.playlistId &&
options.portalType === 'm3u'
) {
await firstValueFrom(
this.playlistsService.setFavorites(
await this.setM3uFavorites(
options.playlistId,
items
.map((item) => item.streamUrl ?? item.channelId ?? '')
.filter((value) => value.length > 0)
)
);
return;
}
@@ -221,12 +332,7 @@ export class UnifiedFavoritesDataService {
(favorite) => !targetIds.has(favorite.trim())
);
await firstValueFrom(
this.playlistsService.setFavorites(
playlistId,
nextFavorites
)
);
await this.setM3uFavorites(playlistId, nextFavorites);
}
)
);
@@ -493,6 +599,23 @@ export class UnifiedFavoritesDataService {
} catch { /* ignore */ }
}
private async setM3uFavorites(
playlistId: string,
favorites: string[]
): Promise<void> {
await firstValueFrom(
this.playlistsService.setFavorites(playlistId, favorites)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
favorites,
} as PlaylistMeta,
})
);
}
private applyOrder(items: UnifiedCollectionItem[], savedOrder: string[]): UnifiedCollectionItem[] {
if (!savedOrder.length) {
return items.slice().sort((a, b) => {
@@ -60,6 +60,8 @@ class StubResizableDirective {
})
class StubGlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input(0);
readonly activeUid = input<string | null>(null);
@@ -293,6 +295,33 @@ describe('UnifiedLiveTabComponent', () => {
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
});
it('passes recent mode and favorite state to the shared live collection list', async () => {
const item = buildLiveItem('m3u');
const favoriteUids = new Set<string>([item.uid]);
const toggledItems: UnifiedCollectionItem[] = [];
const subscription = component.favoriteToggled.subscribe((toggled) =>
toggledItems.push(toggled)
);
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('mode', 'recent');
fixture.componentRef.setInput('favoriteUids', favoriteUids);
fixture.detectChanges();
await fixture.whenStable();
const list = fixture.debugElement.query(
By.directive(StubGlobalFavoritesListComponent)
).componentInstance as StubGlobalFavoritesListComponent;
expect(list.mode()).toBe('recent');
expect(list.favoriteUids()).toBe(favoriteUids);
list.favoriteToggled.emit(list.channels()[0]);
expect(toggledItems).toEqual([item]);
subscription.unsubscribe();
});
it('wraps inline M3U EPG in the live panel with shared date navigation', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
const item = buildLiveItem('m3u');