mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06: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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -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: (
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
+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