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

+8 -5
View File
@@ -255,15 +255,17 @@ test.describe('Electron Recently Viewed', () => {
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
await clearRecentItems(app.mainWindow);
// After clearing all items, the content toggle disappears.
// All content types are cleared simultaneously, so no toggle switch needed.
await clearRecentItems(app.mainWindow, 'Live TV');
await expect(
channelItemByTitle(app.mainWindow, liveTitle)
).toHaveCount(0);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await clearRecentItems(app.mainWindow, 'Movies');
await expect(
contentCardByTitle(app.mainWindow, movieTitle)
).toHaveCount(0);
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await clearRecentItems(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(0);
@@ -564,10 +566,11 @@ const xtreamCredentials = {
password: defaultXtreamPassword,
};
async function clearRecentItems(page: Page): Promise<void> {
async function clearRecentItems(page: Page, typeLabel: string): Promise<void> {
await page
.getByRole('button', { name: 'Clear recently viewed for this section' })
.getByRole('button', { name: `Clear recently viewed ${typeLabel}` })
.click();
await page.getByRole('button', { name: 'Yes' }).click();
}
async function closeUnifiedLiveDetail(page: Page): Promise<void> {
@@ -511,8 +511,17 @@ const electronApi = {
ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId),
dbRemoveRecentItem: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId),
dbGetContentByXtreamId: (xtreamId: number, playlistId: string) =>
ipcRenderer.invoke('DB_GET_CONTENT_BY_XTREAM_ID', xtreamId, playlistId),
dbGetContentByXtreamId: (
xtreamId: number,
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) =>
ipcRenderer.invoke(
'DB_GET_CONTENT_BY_XTREAM_ID',
xtreamId,
playlistId,
contentType
),
dbDeleteAllPlaylists: (operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS', operationId),
dbCancelOperation: (operationId: string) =>
@@ -0,0 +1,94 @@
const andMock = jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
}));
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
jest.mock('drizzle-orm', () => ({
and: (...conditions: unknown[]) => andMock(...conditions),
asc: jest.fn(),
desc: jest.fn(),
eq: (left: unknown, right: unknown) => eqMock(left, right),
inArray: jest.fn(),
or: jest.fn(),
sql: jest.fn(),
}));
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import { getContentByXtreamId } from './content.operations';
function createDbMock(result: unknown[] = []) {
const limit = jest.fn().mockResolvedValue(result);
const where = jest.fn().mockReturnValue({ limit });
const innerJoin = jest.fn().mockReturnValue({ where });
const from = jest.fn().mockReturnValue({ innerJoin });
const select = jest.fn().mockReturnValue({ from });
return {
db: {
select,
} as unknown as AppDatabase,
innerJoin,
limit,
select,
where,
};
}
describe('content.operations', () => {
beforeEach(() => {
andMock.mockClear();
eqMock.mockClear();
});
it('adds the content type filter when resolving by xtream ID', async () => {
const { db, where } = createDbMock([
{
title: 'Krypton',
type: 'series',
xtream_id: 290,
},
]);
const result = await getContentByXtreamId(
db,
290,
'playlist-1',
'series'
);
expect(eqMock).toHaveBeenCalledWith(schema.content.xtreamId, 290);
expect(eqMock).toHaveBeenCalledWith(
schema.categories.playlistId,
'playlist-1'
);
expect(eqMock).toHaveBeenCalledWith(schema.content.type, 'series');
expect(where.mock.calls[0][0].conditions).toHaveLength(3);
expect(result).toEqual(
expect.objectContaining({
title: 'Krypton',
type: 'series',
xtream_id: 290,
})
);
});
it('keeps the legacy lookup path when no content type is provided', async () => {
const { db, where } = createDbMock();
await getContentByXtreamId(db, 290, 'playlist-1');
expect(eqMock).toHaveBeenCalledWith(schema.content.xtreamId, 290);
expect(eqMock).toHaveBeenCalledWith(
schema.categories.playlistId,
'playlist-1'
);
expect(eqMock).not.toHaveBeenCalledWith(schema.content.type, 'series');
expect(where.mock.calls[0][0].conditions).toHaveLength(2);
});
});
@@ -368,8 +368,18 @@ export async function clearXtreamImportCache(
export async function getContentByXtreamId(
db: AppDatabase,
xtreamId: number,
playlistId: string
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) {
const conditions = [
eq(schema.content.xtreamId, xtreamId),
eq(schema.categories.playlistId, playlistId),
];
if (contentType) {
conditions.push(eq(schema.content.type, contentType));
}
const result = await db
.select(selectContentFields())
.from(schema.content)
@@ -377,12 +387,7 @@ export async function getContentByXtreamId(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.content.xtreamId, xtreamId),
eq(schema.categories.playlistId, playlistId)
)
)
.where(and(...conditions))
.limit(1);
return result[0] || null;
@@ -70,9 +70,14 @@ handleWorkerRequest(
handleWorkerRequest(
'DB_GET_CONTENT_BY_XTREAM_ID',
(xtreamId: number, playlistId: string) => ({
(
xtreamId: number,
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) => ({
xtreamId,
playlistId,
contentType,
})
);
@@ -401,11 +401,13 @@ async function executeRequest(message: DbWorkerRequestMessage) {
const payload = message.payload as {
xtreamId: number;
playlistId: string;
contentType?: 'live' | 'movie' | 'series';
};
return getContentByXtreamId(
db,
payload.xtreamId,
payload.playlistId
payload.playlistId,
payload.contentType
);
}
+2 -1
View File
@@ -277,7 +277,8 @@ declare global {
) => Promise<{ success: boolean }>;
dbGetContentByXtreamId: (
xtreamId: number,
playlistId: string
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) => Promise<XtreamContent | null>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (
+31
View File
@@ -280,6 +280,37 @@ Xtream search now guards against stale async responses:
This prevents an older worker response from repainting over a newer query or a
cleared search state.
### Xtream favorites lookup
Xtream favorites must treat `xtream_id` as only partially unique.
Current contract:
1. `xtream_id` can collide across `live`, `movie`, and `series` within the same
playlist.
2. Any DB-backed lookup that starts from an Xtream result card, favorite button,
or detail route must resolve content by:
- `playlist_id`
- `xtream_id`
- `content.type`
3. Favorites UI state for mixed Xtream collections must key entries by
`type + xtream_id`, not `xtream_id` alone.
Why this matters:
- Search results are already type-filtered, so resolving favorites by only
`playlist_id + xtream_id` can favorite the wrong persisted row when IDs
collide.
- Mixed favorites maps keyed only by `xtream_id` can mark an unrelated live row
as favorited when the actual favorite is a movie or series with the same
numeric ID.
Current implementation paths:
1. `apps/electron-backend/src/app/database/operations/content.operations.ts`
2. `libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts`
3. `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts`
### Busy states
The UI now has explicit long-running state for destructive operations:
Vendored
+2 -1
View File
@@ -263,7 +263,8 @@ declare global {
) => Promise<{ success: boolean }>;
dbGetContentByXtreamId: (
xtreamId: number,
playlistId: string
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
) => Promise<any | null>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (
@@ -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);