From 290843ef3f8a8a2c984567bc02ab2a7810744a3d Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Apr 2026 21:35:39 +0200 Subject: [PATCH] fix: restore recent live favorite toggles Entire-Checkpoint: c2cea9c530e6 --- apps/electron-backend-e2e/src/recent.e2e.ts | 29 ++-- .../global-favorites-list.component.html | 2 +- .../global-favorites-list.component.spec.ts | 93 ++++++++++ .../global-favorites-list.component.ts | 27 ++- .../unified-collection-page.component.html | 2 + .../unified-collection-page.component.spec.ts | 57 ++++++- .../unified-collection-page.component.ts | 46 +++++ .../unified-live-tab.component.html | 2 + .../unified-live-tab.component.ts | 8 +- .../unified-favorites-data.service.spec.ts | 157 ++++++++++++++--- .../unified-favorites-data.service.ts | 161 +++++++++++++++--- .../unified-live-tab.component.spec.ts | 29 ++++ 12 files changed, 553 insertions(+), 60 deletions(-) create mode 100644 libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 57d7c768a..054a92abb 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -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 { await episodeCard.click(); } -async function removeLiveRecentItem(page: Page, title: string): Promise { - 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 { diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index 81eb6b61b..6ac9f94cf 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -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)" diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts new file mode 100644 index 000000000..d8d937d82 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -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; + + 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(['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, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index d8bbad6c6..562f74739 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -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(); + readonly mode = input('favorites'); + readonly favoriteUids = input>(new Set()); readonly epgMap = input>(new Map()); readonly progressTick = input(0); readonly activeUid = input(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(), { - getName: (ch) => ch.name, - getAddedAt: (ch) => ch.addedAt, - }); + 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): void { if (!this.canDragDrop()) { return; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index 8e8909d89..fb21f7d15 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -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()" 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 3b6fe8028..713759d85 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 @@ -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(null); + readonly favoriteUids = input>(new Set()); readonly sortMode = input('custom'); readonly removeItem = output(); + readonly favoriteToggled = output(); readonly reorderItems = output(); readonly itemPlayed = output(); readonly autoOpenHandled = output(); @@ -120,10 +123,11 @@ describe('UnifiedCollectionPageComponent', () => { const playlistsLoaded = signal(false); const playlists = signal([]); 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((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([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 = [ { 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 26defbd65..bbc564505 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 @@ -132,6 +132,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { readonly isLoading = signal(true); readonly allItems = signal([]); + readonly favoriteUidSet = signal>(new Set()); readonly selectedContentType = signal( this.historyCollectionViewState()?.selectedContentType ?? 'live' ); @@ -487,6 +488,11 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { async onRemoveItem(item: UnifiedCollectionItem): Promise { 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 { + 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> { + 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(); + } + } + private autoSelectContentType(): void { if (this.selectedDetailItem()) { return; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 93021fdb6..46aa9901f 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -8,6 +8,8 @@ > ('favorites'); readonly searchTerm = input(''); readonly autoOpenItem = input(null); + readonly favoriteUids = input>(new Set()); readonly sortMode = input( DEFAULT_FAVORITES_CHANNEL_SORT_MODE ); readonly removeItem = output(); + readonly favoriteToggled = output(); readonly reorderItems = output(); readonly itemPlayed = output(); readonly autoOpenHandled = output(); @@ -268,7 +270,11 @@ export class UnifiedLiveTabComponent { (candidate) => candidate.uid === channel.uid ); if (item) { - this.removeItem.emit(item); + if (this.mode() === 'favorites') { + this.removeItem.emit(item); + } else { + this.favoriteToggled.emit(item); + } } } 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 f43b14ac2..018837d62 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 @@ -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,40 +101,44 @@ 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([]), }; + store = { + dispatch: jest.fn(), + select: jest.fn(() => + of([ + { + _id: 'm3u-1', + title: 'M3U List', + favorites: [ + 'https://example.com/2.m3u8', + 'channel-1', + ], + }, + { + _id: 'stalker-1', + title: 'Stalker List', + macAddress: '00:11:22:33:44:55', + favorites: stalkerFavorites, + }, + ] satisfies PlaylistMeta[]) + ), + }; TestBed.configureTestingModule({ providers: [ UnifiedFavoritesDataService, { provide: Store, - useValue: { - select: jest.fn(() => - of([ - { - _id: 'm3u-1', - title: 'M3U List', - favorites: [ - 'https://example.com/2.m3u8', - 'channel-1', - ], - }, - { - _id: 'stalker-1', - title: 'Stalker List', - macAddress: '00:11:22:33:44:55', - favorites: stalkerFavorites, - }, - ] satisfies PlaylistMeta[]) - ), - }, + 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) + ); + + 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({ 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 b720efc5e..911837c8c 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 @@ -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 { + 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 { 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 { + 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 { + 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 { + 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 { if (items.length === 0) { return; @@ -114,13 +227,11 @@ export class UnifiedFavoritesDataService { options.playlistId && options.portalType === 'm3u' ) { - await firstValueFrom( - this.playlistsService.setFavorites( - options.playlistId, - items - .map((item) => item.streamUrl ?? item.channelId ?? '') - .filter((value) => value.length > 0) - ) + 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 { + 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) => { 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 610b8dbb8..78ac6b00d 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 @@ -60,6 +60,8 @@ class StubResizableDirective { }) class StubGlobalFavoritesListComponent { readonly channels = input.required(); + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly favoriteUids = input>(new Set()); readonly epgMap = input>(new Map()); readonly progressTick = input(0); readonly activeUid = input(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([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');