diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 8ab3a5b38..a7696d3a9 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -116,6 +116,77 @@ test.describe('Electron Xtream Category Management', () => { await closeElectronApp(app); } }); + + test('persists hidden selections after refreshing from the workspace header', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const portalName = 'Header Refresh Xtream'; + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: portalName, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + + const targetCategory = await pickSidebarCategory(app.mainWindow); + const dialog = await openManageCategoriesDialog(app.mainWindow); + + await dialog + .getByRole('button', { + name: 'Deselect All', + exact: true, + }) + .click(); + await expect( + dialog.locator('.category-item mat-checkbox input:checked') + ).toHaveCount(0); + + await dialog.locator('.search-field input').fill(targetCategory.name); + await toggleManagedCategory(dialog, targetCategory, true); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await app.mainWindow.waitForSelector('mat-dialog-container', { + state: 'detached', + }); + + await expect( + sidebarCategoryById(app.mainWindow, targetCategory.id) + ).toBeVisible(); + + await refreshFromWorkspaceHeader(app.mainWindow); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + await expectVisibleSidebarCategoryIds(app.mainWindow, [ + targetCategory.id, + ]); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, portalName).first().click(); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + await expectVisibleSidebarCategoryIds(app.mainWindow, [ + targetCategory.id, + ]); + } finally { + await closeElectronApp(app); + } + }); }); async function openManageCategoriesDialog(page: Page) { @@ -134,12 +205,46 @@ async function openManageCategoriesDialog(page: Page) { return dialog; } +async function refreshFromWorkspaceHeader(page: Page): Promise { + await page + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .click(); + + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); + await page.waitForSelector('mat-dialog-container', { state: 'detached' }); +} + function sidebarCategoryById(page: Page, categoryId: string): Locator { return page.locator( `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]` ); } +async function expectVisibleSidebarCategoryIds( + page: Page, + expectedIds: string[] +): Promise { + const categories = page.locator( + 'app-workspace-context-panel .category-item:visible' + ); + const actualIds: string[] = []; + const count = await categories.count(); + + for (let index = 0; index < count; index += 1) { + const categoryId = await categories + .nth(index) + .getAttribute('data-category-id'); + + if (categoryId) { + actualIds.push(categoryId.trim()); + } + } + + expect(actualIds).toEqual(expectedIds); +} + async function pickSidebarCategory( page: Page ): Promise<{ id: string; itemCount: number; name: string }> { diff --git a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts new file mode 100644 index 000000000..6992f28f2 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts @@ -0,0 +1,98 @@ +import type { AppDatabase } from '../database.types'; +import { saveCategories } from './category.operations'; + +function createDbMock(existingCount = 0) { + const where = jest.fn().mockResolvedValue([{ count: existingCount }]); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + const onConflictDoNothing = jest.fn().mockResolvedValue(undefined); + const values = jest.fn().mockReturnValue({ onConflictDoNothing }); + const insert = jest.fn().mockReturnValue({ values }); + + return { + db: { + select, + insert, + } as unknown as AppDatabase, + insert, + values, + onConflictDoNothing, + select, + from, + where, + }; +} + +describe('category.operations', () => { + it('restores hidden categories when Xtream API category IDs are strings', async () => { + const { db, values, insert } = createDbMock(); + + await saveCategories( + db, + 'playlist-1', + [ + { category_name: 'News', category_id: '101' }, + { category_name: 'Sports', category_id: '102' }, + ], + 'live', + [102] + ); + + expect(insert).toHaveBeenCalled(); + expect(values).toHaveBeenCalledWith([ + { + playlistId: 'playlist-1', + name: 'News', + type: 'live', + xtreamId: 101, + hidden: false, + }, + { + playlistId: 'playlist-1', + name: 'Sports', + type: 'live', + xtreamId: 102, + hidden: true, + }, + ]); + }); + + it('skips categories whose Xtream IDs are not numeric', async () => { + const { db, values } = createDbMock(); + + await saveCategories( + db, + 'playlist-1', + [ + { category_name: 'Valid', category_id: '201' }, + { category_name: 'Broken', category_id: 'not-a-number' }, + ], + 'movies', + [201] + ); + + expect(values).toHaveBeenCalledWith([ + { + playlistId: 'playlist-1', + name: 'Valid', + type: 'movies', + xtreamId: 201, + hidden: true, + }, + ]); + }); + + it('does not insert categories when all Xtream IDs are invalid', async () => { + const { db, insert } = createDbMock(); + + await saveCategories( + db, + 'playlist-1', + [{ category_name: 'Broken', category_id: 'not-a-number' }], + 'series', + [301] + ); + + expect(insert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index e20c8add6..bc9d1828f 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -2,6 +2,17 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import * as schema from 'database-schema'; import type { AppDatabase } from '../database.types'; +type XtreamCategoryInput = { + category_name: string; + category_id: string | number; +}; + +function normalizeXtreamCategoryId(rawCategoryId: string | number): number | null { + const xtreamId = Number.parseInt(String(rawCategoryId), 10); + + return Number.isNaN(xtreamId) ? null : xtreamId; +} + export async function hasCategories( db: AppDatabase, playlistId: string, @@ -41,10 +52,7 @@ export async function getCategories( export async function saveCategories( db: AppDatabase, playlistId: string, - categories: Array<{ - category_name: string; - category_id: number; - }>, + categories: XtreamCategoryInput[], type: 'live' | 'movies' | 'series', hiddenCategoryXtreamIds?: number[] ): Promise<{ success: boolean }> { @@ -67,13 +75,27 @@ export async function saveCategories( } const hiddenSet = new Set(hiddenCategoryXtreamIds || []); - const values = categories.map((category) => ({ - playlistId, - name: category.category_name, - type, - xtreamId: category.category_id, - hidden: hiddenSet.has(category.category_id), - })); + const values = categories.flatMap((category) => { + const xtreamId = normalizeXtreamCategoryId(category.category_id); + + if (xtreamId === null) { + return []; + } + + return [ + { + playlistId, + name: category.category_name, + type, + xtreamId, + hidden: hiddenSet.has(xtreamId), + }, + ]; + }); + + if (values.length === 0) { + return { success: true }; + } await db .insert(schema.categories) diff --git a/apps/electron-backend/src/app/events/database/category.events.ts b/apps/electron-backend/src/app/events/database/category.events.ts index 8c9dcab11..e25d97bce 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -27,7 +27,7 @@ handleWorkerRequest( playlistId: string, categories: Array<{ category_name: string; - category_id: number; + category_id: string | number; }>, type: 'live' | 'movies' | 'series', hiddenCategoryXtreamIds?: number[] diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 98240ea68..825b28b24 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -289,7 +289,7 @@ async function executeRequest(message: DbWorkerRequestMessage) { playlistId: string; categories: Array<{ category_name: string; - category_id: number; + category_id: string | number; }>; type: 'live' | 'movies' | 'series'; hiddenCategoryXtreamIds?: number[];