mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix: restore recent live favorite toggles
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
1 parent
911ec1369c
commit
290843ef3f
12 files changed
+553
-60
No files matched your search
@@ -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[]> {
|
||||
|
||||
+1
-1
@@ -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)"
|
||||
|
||||
+93
@@ -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,
|
||||
};
|
||||
}
|
||||
+22
-5
@@ -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(), {
|
||||
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<EnrichedUnifiedFavorite[]>): void {
|
||||
if (!this.canDragDrop()) {
|
||||
return;
|
||||
|
||||
+2
@@ -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()"
|
||||
|
||||
+55
-2
@@ -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 = [
|
||||
{
|
||||
|
||||
+46
@@ -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;
|
||||
|
||||
+2
@@ -8,6 +8,8 @@
|
||||
>
|
||||
<app-global-favorites-list
|
||||
[channels]="channelsForList()"
|
||||
[mode]="mode()"
|
||||
[favoriteUids]="favoriteUids()"
|
||||
[epgMap]="epgMap()"
|
||||
[progressTick]="progressTick()"
|
||||
[activeUid]="activeUid()"
|
||||
|
||||
+7
-1
@@ -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) {
|
||||
this.removeItem.emit(item);
|
||||
if (this.mode() === 'favorites') {
|
||||
this.removeItem.emit(item);
|
||||
} else {
|
||||
this.favoriteToggled.emit(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+137
-20
@@ -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<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(
|
||||
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<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');
|
||||
|
||||
Reference in new issue
Block a user