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: (