fix(xtream): enhance content retrieval by adding content type filtering for Xtream ID lookups #756

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-19 22:33:49 +02:00
1 parent d3231151e7
commit ff605fc024
20 files changed
+462 -44

No files matched your search

@@ -426,9 +426,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async getContentByXtreamId(
xtreamId: number,
playlistId: string
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContentItem | null> {
return this.dbService.getContentByXtreamId(xtreamId, playlistId);
return this.dbService.getContentByXtreamId(
xtreamId,
playlistId,
contentType
);
}
// =========================================================================
@@ -604,9 +604,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getContentByXtreamId(
xtreamId: number,
playlistId: string
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContentItem | null> {
for (const type of ['live', 'movie', 'series']) {
const types = contentType
? [contentType]
: (['live', 'movie', 'series'] as const);
for (const type of types) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
@@ -322,7 +322,8 @@ export interface IXtreamDataSource {
*/
getContentByXtreamId(
xtreamId: number,
playlistId: string
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContentItem | null>;
// =========================================================================
@@ -0,0 +1,125 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore } from '@ngrx/signals';
import { DatabaseService } from 'services';
import { FavoritesService } from './services/favorites.service';
import { withFavorites } from './with-favorites.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const TestFavoritesStore = signalStore(withFavorites());
describe('withFavorites', () => {
let store: InstanceType<typeof TestFavoritesStore>;
let databaseService: {
getContentByXtreamId: jest.Mock;
};
let favoritesService: {
addToFavorites: jest.Mock;
isFavorite: jest.Mock;
removeFromFavorites: jest.Mock;
};
beforeEach(() => {
databaseService = {
getContentByXtreamId: jest.fn(),
};
favoritesService = {
addToFavorites: jest.fn().mockResolvedValue(undefined),
isFavorite: jest.fn().mockResolvedValue(false),
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
TestFavoritesStore,
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: FavoritesService,
useValue: favoritesService,
},
],
});
store = TestBed.inject(TestFavoritesStore);
});
it('looks favorites up with the requested content type before adding one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
xtream_id: 290,
});
const result = await store.toggleFavorite(290, 'playlist-1', 'series');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(favoritesService.addToFavorites).toHaveBeenCalledWith({
content_id: 3941697,
playlist_id: 'playlist-1',
});
expect(result).toBe(true);
expect(store.isFavorite()).toBe(true);
});
it('looks favorites up with the requested content type before removing one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
id: 3867578,
title: 'SE: V Film Premiere FHD',
type: 'live',
xtream_id: 290,
});
patchState(store, { isFavorite: true });
const result = await store.toggleFavorite(290, 'playlist-1', 'live');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'live'
);
expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith(
3867578,
'playlist-1'
);
expect(result).toBe(false);
expect(store.isFavorite()).toBe(false);
});
it('checks favorite state against the matching content type', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
id: 3829429,
title: 'Dragon Ball Heroes',
type: 'series',
xtream_id: 31,
});
favoritesService.isFavorite.mockResolvedValue(true);
await store.checkFavoriteStatus(31, 'playlist-1', 'series');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
31,
'playlist-1',
'series'
);
expect(favoritesService.isFavorite).toHaveBeenCalledWith(
3829429,
'playlist-1'
);
expect(store.isFavorite()).toBe(true);
});
});
@@ -21,15 +21,19 @@ export const withFavorites = function () {
dbService = inject(DatabaseService),
favoritesService = inject(FavoritesService)
) => ({
async toggleFavorite(xtreamId: number, playlistId: string) {
async toggleFavorite(
xtreamId: number,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
) {
if (!xtreamId || !playlistId) {
return false;
}
// Get content by xtream ID
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId
playlistId,
contentType
);
if (!content) {
logger.error(
@@ -62,17 +66,18 @@ export const withFavorites = function () {
async checkFavoriteStatus(
xtreamId: number,
playlistId: string
playlistId: string,
contentType: 'live' | 'movie' | 'series'
) {
if (!xtreamId || !playlistId) {
patchState(store, { isFavorite: false });
return;
}
// Get content by xtream ID
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId
playlistId,
contentType
);
if (!content) {
patchState(store, { isFavorite: false });
@@ -39,7 +39,7 @@
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="favorites.get(item.xtream_id) ?? false"
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
(clicked)="playClicked.emit(item)"
(favoriteToggled)="toggleFavorite($event, item)"
/>
@@ -5,6 +5,7 @@ import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import {
FavoriteItem,
EpgQueueService,
FavoritesService,
XtreamStore,
@@ -52,11 +53,16 @@ describe('PortalChannelsListComponent', () => {
currentPlaylist,
selectedCategoryId,
setSelectedCategory: jest.fn(),
toggleFavorite: jest.fn().mockResolvedValue(true),
};
const epgResults$ = new Subject<{ streamId: number; items: unknown[] }>();
const favoritesService = {
getFavorites: jest.fn().mockReturnValue(of([] as FavoriteItem[])),
};
beforeEach(async () => {
storeSignals.setSelectedCategory.mockClear();
storeSignals.toggleFavorite.mockClear();
selectedChannels.set([]);
selectedItem.set(null);
epgItems.set([]);
@@ -64,6 +70,7 @@ describe('PortalChannelsListComponent', () => {
selectedContentType.set('live');
currentPlaylist.set(null);
selectedCategoryId.set(1);
favoritesService.getFavorites.mockReturnValue(of([] as FavoriteItem[]));
await TestBed.configureTestingModule({
imports: [PortalChannelsListComponent, NoopAnimationsModule],
@@ -100,9 +107,7 @@ describe('PortalChannelsListComponent', () => {
},
{
provide: FavoritesService,
useValue: {
getFavorites: jest.fn().mockReturnValue(of([])),
},
useValue: favoritesService,
},
{
provide: EpgQueueService,
@@ -221,4 +226,74 @@ describe('PortalChannelsListComponent', () => {
);
expect(component.currentProgramsProgress.get(50)).toBeCloseTo(50, 1);
});
it('does not mark a live item as favorite when only a colliding movie ID is favorited', () => {
favoritesService.getFavorites.mockReturnValue(
of([
{
content_id: 42,
playlist_id: 'playlist-1',
type: 'movie',
title: 'Krypton',
category_id: 7,
xtream_id: 290,
},
] satisfies FavoriteItem[])
);
selectedTypeContentLoading.set(false);
selectedChannels.set([
{
title: 'SE: V Film Premiere FHD',
type: 'live',
xtream_id: 290,
},
]);
currentPlaylist.set({
id: 'playlist-1',
password: 'secret',
serverUrl: 'http://demo.example',
username: 'demo',
});
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.favorites.get('movie:290')).toBe(true);
expect(component.favorites.get('live:290')).toBeUndefined();
expect(
component.favoriteKeyFor({
title: 'SE: V Film Premiere FHD',
type: 'live',
xtream_id: 290,
})
).toBe('live:290');
});
it('passes the live content type when toggling a channel favorite', async () => {
selectedTypeContentLoading.set(false);
currentPlaylist.set({
id: 'playlist-1',
password: 'secret',
serverUrl: 'http://demo.example',
username: 'demo',
});
fixture.detectChanges();
fixture.componentInstance.toggleFavorite(
new MouseEvent('click'),
{
title: 'Cartoon Network',
xtream_id: 253,
}
);
await Promise.resolve();
expect(storeSignals.toggleFavorite).toHaveBeenCalledWith(
253,
'playlist-1',
'live'
);
expect(fixture.componentInstance.favorites.get('live:253')).toBe(true);
});
});
@@ -42,6 +42,7 @@ export interface XtreamChannelListItem {
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
readonly type?: 'live' | 'movie' | 'series' | 'vod';
readonly xtream_id: number;
}
@@ -106,7 +107,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
);
});
favorites = new Map<number, boolean>();
favorites = new Map<string, boolean>();
epgPrograms = new Map<number, EpgProgram>();
currentProgramsProgress = new Map<number, number>();
@@ -147,7 +148,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
.getFavorites(playlist.id)
.subscribe((favorites) => {
favorites.forEach((fav) => {
this.favorites.set(fav.xtream_id, true);
this.favorites.set(
this.getFavoriteKey(fav.xtream_id, fav.type),
true
);
});
});
}
@@ -253,18 +257,57 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
return;
}
const favoriteKey = this.favoriteKeyFor(item);
const contentType = this.getContentTypeForItem(item);
this.xtreamStore
.toggleFavorite(item.xtream_id, playlistId)
.toggleFavorite(item.xtream_id, playlistId, contentType)
.then((result: boolean) => {
if (result) {
this.favorites.set(item.xtream_id, true);
this.favorites.set(favoriteKey, true);
} else {
this.favorites.delete(item.xtream_id);
this.favorites.delete(favoriteKey);
}
this.cdr.detectChanges();
});
}
favoriteKeyFor(item: XtreamChannelListItem): string {
return this.getFavoriteKey(
item.xtream_id,
item.type ?? this.xtreamStore.selectedContentType()
);
}
private getFavoriteKey(
xtreamId: number,
type?: 'live' | 'movie' | 'series' | 'vod'
): string {
return `${this.normalizeContentType(type)}:${xtreamId}`;
}
private getContentTypeForItem(
item: XtreamChannelListItem
): 'live' | 'movie' | 'series' {
return this.normalizeContentType(
item.type ?? this.xtreamStore.selectedContentType()
);
}
private normalizeContentType(
type?: 'live' | 'movie' | 'series' | 'vod'
): 'live' | 'movie' | 'series' {
if (type === 'movie' || type === 'vod') {
return 'movie';
}
if (type === 'series') {
return 'series';
}
return 'live';
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
@@ -235,7 +235,8 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
toggleFavorite(): void {
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.serialId,
this.xtreamStore.currentPlaylist().id
this.xtreamStore.currentPlaylist().id,
'series'
);
}
@@ -365,7 +366,11 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
categoryId: Number(categoryId),
});
const serialXtreamId = Number(serialId);
this.xtreamStore.checkFavoriteStatus(serialXtreamId, playlistId);
this.xtreamStore.checkFavoriteStatus(
serialXtreamId,
playlistId,
'series'
);
void this.loadSeriesPlaybackPositions(playlistId, serialXtreamId);
}
}
@@ -391,7 +391,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
toggleFavorite(): void {
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.vodId,
this.xtreamStore.currentPlaylist().id
this.xtreamStore.currentPlaylist().id,
'movie'
);
}
@@ -495,7 +496,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
vodId: String(vodId),
categoryId,
});
this.xtreamStore.checkFavoriteStatus(vodId, playlistId);
this.xtreamStore.checkFavoriteStatus(vodId, playlistId, 'movie');
void this.loadVodPlaybackPosition(playlistId, vodId);
}
@@ -781,12 +781,14 @@ export class DatabaseService {
*/
async getContentByXtreamId(
xtreamId: number,
playlistId: string
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContent | null> {
try {
return await window.electron.dbGetContentByXtreamId(
xtreamId,
playlistId
playlistId,
contentType
);
} catch (error) {
console.error('Error getting content by xtream ID:', error);