mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
fix(xtream): enhance content retrieval by adding content type filtering for Xtream ID lookups #756
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
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 });
|
||||
|
||||
+1
-1
@@ -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)"
|
||||
/>
|
||||
|
||||
+78
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
+48
-5
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user