From ff605fc02452a857fac30defbf37fa3219ae705f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 19 Apr 2026 22:33:49 +0200 Subject: [PATCH] fix(xtream): enhance content retrieval by adding content type filtering for Xtream ID lookups #756 Entire-Checkpoint: c6e522b4276c --- apps/electron-backend-e2e/src/recent.e2e.ts | 13 +- .../src/app/api/main.preload.ts | 13 +- .../operations/content.operations.spec.ts | 94 +++++++++++++ .../database/operations/content.operations.ts | 19 ++- .../src/app/events/database/content.events.ts | 7 +- .../src/app/workers/database.worker.ts | 4 +- apps/web/src/typings.d.ts | 3 +- docs/architecture/sqlite-db-worker.md | 31 +++++ global.d.ts | 3 +- .../electron-xtream-data-source.ts | 9 +- .../data-sources/pwa-xtream-data-source.ts | 9 +- .../xtream-data-source.interface.ts | 3 +- .../src/lib/with-favorites.feature.spec.ts | 125 ++++++++++++++++++ .../src/lib/with-favorites.feature.ts | 17 ++- .../portal-channels-list.component.html | 2 +- .../portal-channels-list.component.spec.ts | 81 +++++++++++- .../portal-channels-list.component.ts | 53 +++++++- .../serial-details.component.ts | 9 +- .../vod-details-route.component.ts | 5 +- .../src/lib/database-electron.service.ts | 6 +- 20 files changed, 462 insertions(+), 44 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/content.operations.spec.ts create mode 100644 libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 3cc9efcac..9d5c0b2e6 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -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 { +async function clearRecentItems(page: Page, typeLabel: string): Promise { 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 { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 1a7329072..f6e91df42 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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) => diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts new file mode 100644 index 000000000..f09809da8 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -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); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index e310b2242..111ba3804 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -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; diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 655c8b65d..0bdea56bd 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -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, }) ); diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 825b28b24..794119716 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -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 ); } diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 658be0933..2e3c516ab 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -277,7 +277,8 @@ declare global { ) => Promise<{ success: boolean }>; dbGetContentByXtreamId: ( xtreamId: number, - playlistId: string + playlistId: string, + contentType?: 'live' | 'movie' | 'series' ) => Promise; dbGetAppState: (key: string) => Promise; dbSetAppState: ( diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index fc3dbc513..ab5f2837b 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -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: diff --git a/global.d.ts b/global.d.ts index b47580a28..7fc139412 100644 --- a/global.d.ts +++ b/global.d.ts @@ -263,7 +263,8 @@ declare global { ) => Promise<{ success: boolean }>; dbGetContentByXtreamId: ( xtreamId: number, - playlistId: string + playlistId: string, + contentType?: 'live' | 'movie' | 'series' ) => Promise; dbGetAppState: (key: string) => Promise; dbSetAppState: ( diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 84bb4dbf5..e2b954916 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -426,9 +426,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { async getContentByXtreamId( xtreamId: number, - playlistId: string + playlistId: string, + contentType?: 'live' | 'movie' | 'series' ): Promise { - return this.dbService.getContentByXtreamId(xtreamId, playlistId); + return this.dbService.getContentByXtreamId( + xtreamId, + playlistId, + contentType + ); } // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index fb9c8d212..70f499ee6 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -604,9 +604,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getContentByXtreamId( xtreamId: number, - playlistId: string + playlistId: string, + contentType?: 'live' | 'movie' | 'series' ): Promise { - 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) || []; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index aa650c3e5..6defbac3f 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -322,7 +322,8 @@ export interface IXtreamDataSource { */ getContentByXtreamId( xtreamId: number, - playlistId: string + playlistId: string, + contentType?: 'live' | 'movie' | 'series' ): Promise; // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts new file mode 100644 index 000000000..b931d8d1e --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts @@ -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; + 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); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts index b03286529..6c24e0bb0 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts @@ -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 }); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 57166dd1e..1eeaa248d 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -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)" /> diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 91ebc1d7f..dc3c35848 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -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); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 368299c56..bd36f8ece 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -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(); + favorites = new Map(); epgPrograms = new Map(); currentProgramsProgress = new Map(); @@ -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(); } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 8815b5b08..8622cf107 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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); } } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 88728c8aa..1bac200fd 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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); } diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 684e65674..7062246c7 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -781,12 +781,14 @@ export class DatabaseService { */ async getContentByXtreamId( xtreamId: number, - playlistId: string + playlistId: string, + contentType?: 'live' | 'movie' | 'series' ): Promise { try { return await window.electron.dbGetContentByXtreamId( xtreamId, - playlistId + playlistId, + contentType ); } catch (error) { console.error('Error getting content by xtream ID:', error);