diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index d14f62e1e..ef2fb8ce9 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -4,6 +4,8 @@ import { addXtreamPortal, clickCategoryByNameExact, closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, expect, launchElectronApp, openWorkspaceSection, @@ -26,19 +28,28 @@ test.describe('Electron Catalog Sorting', () => { request, }) => { await resetMockServers(request, ['xtream']); - const fixture = await fetchXtreamLiveFixture(request); + const fixture = await fetchXtreamLiveFixture(request, xtreamCredentials); const expectedServerOrder = fixture.items .map((item) => getXtreamTitle(item)) + .filter((title, index, titles) => titles.indexOf(title) === index) .slice(0, 5); - const expectedAscending = [...expectedServerOrder] + const expectedAscending = fixture.items + .map((item) => getXtreamTitle(item)) + .filter((title, index, titles) => titles.indexOf(title) === index) .sort(collator.compare) .slice(0, 5); - const expectedDescending = [...expectedAscending].reverse(); + const expectedDescending = fixture.items + .map((item) => getXtreamTitle(item)) + .filter((title, index, titles) => titles.indexOf(title) === index) + .sort(collator.compare) + .reverse() + .slice(0, 5); const app = await launchElectronApp(dataDir); try { await addXtreamPortal(app.mainWindow); await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); await expectVisibleChannelTitles(app.mainWindow, expectedServerOrder); @@ -64,19 +75,32 @@ test.describe('Electron Catalog Sorting', () => { request, }) => { await resetMockServers(request, ['xtream']); - const vodFixture = await fetchXtreamVodFixture(request); - const seriesFixture = await fetchXtreamSeriesFixture(request); + const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials); + const seriesFixture = await fetchXtreamSeriesFixture( + request, + xtreamCredentials + ); const expectedVodDateDesc = [...vodFixture.items] .sort((left, right) => getXtreamDateValue(right) - getXtreamDateValue(left)) - .map((item) => getXtreamTitle(item)); - const expectedVodDateAsc = [...expectedVodDateDesc].reverse(); + .map((item) => getXtreamTitle(item)) + .slice(0, visibleComparisonSize); + const expectedVodDateAsc = [...vodFixture.items] + .sort((left, right) => getXtreamDateValue(left) - getXtreamDateValue(right)) + .map((item) => getXtreamTitle(item)) + .slice(0, visibleComparisonSize); const expectedVodNameAsc = [...vodFixture.items] .map((item) => getXtreamTitle(item)) - .sort(collator.compare); - const expectedVodNameDesc = [...expectedVodNameAsc].reverse(); + .sort(collator.compare) + .slice(0, visibleComparisonSize); + const expectedVodNameDesc = [...vodFixture.items] + .map((item) => getXtreamTitle(item)) + .sort(collator.compare) + .reverse() + .slice(0, visibleComparisonSize); const expectedSeriesNameAsc = [...seriesFixture.items] .map((item) => getXtreamTitle(item)) - .sort(collator.compare); + .sort(collator.compare) + .slice(0, visibleComparisonSize); const app = await launchElectronApp(dataDir); try { @@ -147,6 +171,11 @@ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base', }); +const visibleComparisonSize = 8; +const xtreamCredentials = { + username: defaultXtreamUsername, + password: defaultXtreamPassword, +}; async function setLiveSortMode( page: Page, @@ -173,7 +202,10 @@ async function expectVisibleChannelTitles( expectedTitles: string[] ): Promise { await expect - .poll(() => visibleChannelTitles(page)) + .poll(async () => { + const titles = await visibleChannelTitles(page); + return titles.slice(0, expectedTitles.length); + }) .toEqual(expectedTitles); } @@ -182,7 +214,10 @@ async function expectVisibleGridTitles( expectedTitles: string[] ): Promise { await expect - .poll(() => visibleGridTitles(page)) + .poll(async () => { + const titles = await visibleGridTitles(page); + return titles.slice(0, expectedTitles.length); + }) .toEqual(expectedTitles); } @@ -190,12 +225,29 @@ async function visibleChannelTitles(page: Page): Promise { return page .locator('[data-test-id="channel-item"] .channel-name') .allInnerTexts() - .then((titles: string[]) => titles.map((title) => title.trim())); + .then((titles: string[]) => + uniqueTitles(titles.map((title) => title.trim())) + ); } async function visibleGridTitles(page: Page): Promise { return page .locator('.category-content-layout mat-card .title') .allInnerTexts() - .then((titles: string[]) => titles.map((title) => title.trim())); + .then((titles: string[]) => + uniqueTitles(titles.map((title) => title.trim())) + ); +} + +function uniqueTitles(titles: string[]): string[] { + const seen = new Set(); + + return titles.filter((title) => { + if (seen.has(title)) { + return false; + } + + seen.add(title); + return true; + }); } diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index f46a6a1a3..84238514c 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -1,15 +1,17 @@ -import { Page } from '@playwright/test'; +import { Locator, Page } from '@playwright/test'; import { addXtreamPortal, - clickCategoryByNameExact, + clickCategoryById, closeElectronApp, expect, launchElectronApp, openSources, + openWorkspaceSection, refreshSource, resetMockServers, + sourceRowByTitle, test, - waitForXtreamCatalog, + waitForSourceRowIdle, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; @@ -27,65 +29,65 @@ test.describe('Electron Xtream Category Management', () => { name: portalName, }); await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); - const categoryNames = await app.mainWindow - .locator('app-workspace-context-panel .category-item .nav-item-label') - .allInnerTexts(); - const targetCategory = categoryNames[0]?.trim(); - - expect(targetCategory).toBeTruthy(); + const targetCategory = await pickSidebarCategory(app.mainWindow); let dialog = await openManageCategoriesDialog(app.mainWindow); - await dialog.getByRole('button', { name: 'Deselect All' }).click(); + await dialog + .getByRole('button', { + name: 'Deselect All', + exact: true, + }) + .click(); await expect(dialog.locator('.category-item mat-checkbox input:checked')).toHaveCount( 0 ); - await dialog.getByRole('button', { name: 'Select All' }).click(); + await dialog + .getByRole('button', { + name: 'Select All', + exact: true, + }) + .click(); await expect( dialog.locator('.category-item mat-checkbox input:checked').first() ).toBeVisible(); - await dialog.locator('.search-field input').fill(targetCategory!); - await expect(dialog.locator('.category-item')).toHaveCount(1); - await dialog.locator('.category-item').first().click(); + await dialog.locator('.search-field input').fill(targetCategory.name); + await toggleManagedCategory(dialog, targetCategory); await dialog.getByRole('button', { name: 'Save', exact: true }).click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', }); - await expect( - app.mainWindow - .locator('app-workspace-context-panel .category-item') - .filter({ hasText: targetCategory! }) - ).toHaveCount(0); + await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount( + 0 + ); await openSources(app.mainWindow); await refreshSource(app.mainWindow, portalName, { confirm: true }); - await waitForXtreamCatalog(app.mainWindow); - await expect( - app.mainWindow - .locator('app-workspace-context-panel .category-item') - .filter({ hasText: targetCategory! }) - ).toHaveCount(0); + await waitForSourceRowIdle(app.mainWindow, portalName); + await sourceRowByTitle(app.mainWindow, portalName).first().click(); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount( + 0 + ); dialog = await openManageCategoriesDialog(app.mainWindow); - await dialog.locator('.search-field input').fill(targetCategory!); - await expect(dialog.locator('.category-item')).toHaveCount(1); - await dialog.locator('.category-item').first().click(); + await dialog.locator('.search-field input').fill(targetCategory.name); + await toggleManagedCategory(dialog, targetCategory); await dialog.getByRole('button', { name: 'Save', exact: true }).click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', }); - await expect( - app.mainWindow - .locator('app-workspace-context-panel .category-item') - .filter({ hasText: targetCategory! }) - .first() - ).toBeVisible(); - await clickCategoryByNameExact(app.mainWindow, targetCategory!); + await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount( + 1 + ); + await clickCategoryById(app.mainWindow, targetCategory.id); } finally { await closeElectronApp(app); } @@ -94,9 +96,89 @@ test.describe('Electron Xtream Category Management', () => { async function openManageCategoriesDialog(page: Page) { await page.getByRole('button', { name: 'Manage categories' }).click(); - const dialog = page.locator('mat-dialog-container'); + const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); - await expect(dialog.getByText('Select All')).toBeVisible(); + await expect( + dialog.getByRole('button', { name: 'Select All', exact: true }) + ).toBeVisible(); return dialog; } + +function sidebarCategoryById(page: Page, categoryId: string): Locator { + return page.locator( + `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]` + ); +} + +async function pickSidebarCategory( + page: Page +): Promise<{ id: string; itemCount: number; name: string }> { + const categories = page.locator( + 'app-workspace-context-panel .category-item:visible' + ); + const count = await categories.count(); + const candidates: Array<{ id: string; itemCount: number; name: string }> = []; + + for (let index = 0; index < count; index += 1) { + const category = categories.nth(index); + const id = (await category.getAttribute('data-category-id'))?.trim() ?? ''; + const name = + (await category.locator('.nav-item-label').textContent())?.trim() ?? ''; + const countText = + (await category.locator('.item-count').textContent())?.trim() ?? ''; + const itemCount = Number.parseInt(countText, 10) || 0; + + if (id && name && itemCount > 0) { + candidates.push({ id, itemCount, name }); + } + } + + const nameCounts = new Map(); + for (const candidate of candidates) { + nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1); + } + + const preferredCandidate = + candidates.find((candidate) => nameCounts.get(candidate.name) === 1) ?? + candidates[0]; + + if (!preferredCandidate) { + throw new Error('No visible Xtream category with content was found.'); + } + + return preferredCandidate; +} + +async function toggleManagedCategory( + dialog: Locator, + targetCategory: { + itemCount: number; + name: string; + } +): Promise { + const categories = dialog.locator('.category-item'); + const count = await categories.count(); + + for (let index = 0; index < count; index += 1) { + const categoryRow = categories.nth(index); + const name = + (await categoryRow.locator('.category-name').textContent())?.trim() ?? + ''; + const countText = + (await categoryRow.locator('.item-count').textContent())?.trim() ?? ''; + const itemCount = Number.parseInt(countText.replace(/[()]/g, ''), 10) || 0; + + if (name !== targetCategory.name || itemCount !== targetCategory.itemCount) { + continue; + } + + await expect(categoryRow).toBeVisible(); + await categoryRow.click(); + return; + } + + throw new Error( + `Could not find category "${targetCategory.name}" with count ${targetCategory.itemCount} in the management dialog.` + ); +} diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index b8614f818..276ee88ef 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -339,7 +339,12 @@ export async function saveSettings(page: Page): Promise { } export async function goToDashboard(page: Page): Promise { - await page.locator('a[href$="/workspace/dashboard"]').click(); + const dashboardLink = page + .locator('a.brand[href$="/workspace/dashboard"]') + .first(); + + await expect(dashboardLink).toBeVisible(); + await dashboardLink.click(); await page.waitForURL(/\/workspace\/dashboard$/); } @@ -544,21 +549,52 @@ export async function switchUnifiedCollectionScope( page: Page, scopeLabel: 'This playlist' | 'All playlists' ): Promise { - const toggle = page - .locator('.scope-toggle') - .getByRole('button', { name: scopeLabel, exact: true }); + const toggleGroup = page.locator('.scope-toggle'); - await expect(toggle).toBeVisible(); - await toggle.click(); + await expect(toggleGroup).toBeVisible(); + await clickButtonToggleOption(toggleGroup, scopeLabel); } export async function switchUnifiedCollectionContent( page: Page, contentLabel: 'Live TV' | 'Movies' | 'Series' ): Promise { - const toggle = page - .locator('.content-toggle') - .getByRole('button', { name: contentLabel, exact: true }); + const toggleGroup = page.locator('.content-toggle'); + + await expect(toggleGroup).toBeVisible(); + await clickButtonToggleOption(toggleGroup, contentLabel); +} + +async function clickButtonToggleOption( + toggleGroup: Locator, + label: string +): Promise { + const radio = toggleGroup.getByRole('radio', { + name: label, + exact: true, + }); + + if ((await radio.count()) > 0) { + await expect(radio.first()).toBeVisible(); + await radio.first().click(); + return; + } + + const button = toggleGroup.getByRole('button', { + name: label, + exact: true, + }); + + if ((await button.count()) > 0) { + await expect(button.first()).toBeVisible(); + await button.first().click(); + return; + } + + const toggle = toggleGroup + .locator('mat-button-toggle') + .filter({ hasText: flexibleTextPattern(label) }) + .first(); await expect(toggle).toBeVisible(); await toggle.click(); @@ -606,7 +642,8 @@ export async function clickCategoryById( `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible` ); - await expect(category.first()).toBeVisible(); + await expect(category.first()).toBeVisible({ timeout: 20000 }); + await category.first().scrollIntoViewIfNeeded(); await category.first().click(); } @@ -614,16 +651,17 @@ export async function clickCategoryByNameExact( page: Page, categoryName: string ): Promise { - const category = page + const categories = page .locator('app-workspace-context-panel .category-item:visible') .filter({ has: page.locator('.nav-item-label', { hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`), }), - }) - .first(); + }); + const category = await pickPreferredCategory(categories); await expect(category).toBeVisible(); + await category.scrollIntoViewIfNeeded(); await category.click(); } @@ -868,7 +906,6 @@ export async function waitForM3uCatalog(page: Page): Promise { export async function waitForXtreamCatalog(page: Page): Promise { await page.waitForURL(/\/workspace\/xtreams\/.+/); await waitForXtreamImportToFinish(page); - await waitForXtreamCategoryCounts(page); const categories = page.locator( 'app-workspace-context-panel .category-item' @@ -883,7 +920,9 @@ export async function waitForXtreamCatalog(page: Page): Promise { await expect(contentItems.first()).toBeVisible({ timeout: 5000 }); return; } catch { - await categories.first().click(); + const category = await pickPreferredCategory(categories); + await category.scrollIntoViewIfNeeded(); + await category.click(); await expect(contentItems.first()).toBeVisible({ timeout: 20000 }); } } @@ -902,23 +941,6 @@ export async function expectPlaylistUpdatedToast(page: Page): Promise { }); } -async function waitForXtreamCategoryCounts(page: Page): Promise { - await expect - .poll( - async () => { - const texts = await page - .locator( - 'app-workspace-context-panel .category-item .item-count' - ) - .allInnerTexts(); - - return texts.some((text) => Number.parseInt(text, 10) > 0); - }, - { timeout: 30000 } - ) - .toBeTruthy(); -} - export async function waitForXtreamImportToFinish(page: Page): Promise { const overlay = page.locator('.workspace-loading-overlay'); @@ -931,6 +953,32 @@ export async function waitForXtreamImportToFinish(page: Page): Promise { await expect(overlay).toHaveCount(0, { timeout: 30000 }); } +async function pickPreferredCategory(categories: Locator): Promise { + const count = await categories.count(); + let fallback: Locator | null = null; + + for (let index = 0; index < count; index += 1) { + const candidate = categories.nth(index); + + if (!(await candidate.isVisible())) { + continue; + } + + fallback ??= candidate; + + const countText = + (await candidate.locator('.item-count').first().textContent()) ?? + ''; + const itemCount = Number.parseInt(countText.trim(), 10); + + if (Number.isFinite(itemCount) && itemCount > 0) { + return candidate; + } + } + + return fallback ?? categories.first(); +} + export async function waitForStalkerCatalog(page: Page): Promise { await page.waitForURL(/\/workspace\/stalker\/.+/); diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 814c70221..e79b9e07f 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -4,12 +4,16 @@ import { addXtreamPortal, channelItemByTitle, clickCategoryById, + clickCategoryByNameExact, clickGridListCardByTitle, closeElectronApp, contentCardByTitle, + defaultXtreamPassword, + defaultXtreamUsername, expect, importM3uPlaylistFromNativeDialog, launchElectronApp, + openWorkspaceSection, openPlaylistFavorites, openSources, resetMockServers, @@ -105,9 +109,12 @@ test.describe('Electron Favorites', () => { request, }) => { await resetMockServers(request, ['xtream']); - const liveFixture = await fetchXtreamLiveFixture(request); - const vodFixture = await fetchXtreamVodFixture(request); - const seriesFixture = await fetchXtreamSeriesFixture(request); + const liveFixture = await fetchXtreamLiveFixture(request, xtreamCredentials); + const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials); + const seriesFixture = await fetchXtreamSeriesFixture( + request, + xtreamCredentials + ); const [liveTitle] = pickDistinctTitles(liveFixture.items, getXtreamTitle); const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle); const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getXtreamTitle); @@ -120,17 +127,27 @@ test.describe('Electron Favorites', () => { }); await waitForXtreamWorkspaceReady(app.mainWindow); - await clickCategoryById(app.mainWindow, liveFixture.categoryId); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact( + app.mainWindow, + liveFixture.categoryName + ); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await app.mainWindow.getByRole('link', { name: 'Movies', exact: true }).click(); - await clickCategoryById(app.mainWindow, vodFixture.categoryId); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); await clickGridListCardByTitle(app.mainWindow, movieTitle); await addCurrentDetailToFavorites(app.mainWindow); await goBackFromDetail(app.mainWindow); await app.mainWindow.getByRole('link', { name: 'Series', exact: true }).click(); - await clickCategoryById(app.mainWindow, seriesFixture.categoryId); + await clickCategoryByNameExact( + app.mainWindow, + seriesFixture.categoryName + ); await clickGridListCardByTitle(app.mainWindow, seriesTitle); await addCurrentDetailToFavorites(app.mainWindow); await goBackFromDetail(app.mainWindow); @@ -264,6 +281,11 @@ test.describe('Electron Favorites', () => { }); }); +const xtreamCredentials = { + username: defaultXtreamUsername, + password: defaultXtreamPassword, +}; + async function addCurrentDetailToFavorites(page: Page): Promise { const addButton = page.locator('button.favorite-btn').first(); diff --git a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts index 2af746079..92153c111 100644 --- a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts +++ b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts @@ -55,6 +55,10 @@ type StalkerOrderedList = { data: T[]; }; +type StalkerSeasonLike = { + id?: number | string; +}; + export type StalkerContentItem = { id?: number | string; name?: string; @@ -128,6 +132,30 @@ export async function fetchStalkerCategoryFixture( const items = itemsResponse.payload.js.data ?? []; if (items.length > 0) { + if (type === 'series') { + const playableSeries = await findPlayableStalkerSeriesItem( + request, + items + ); + + if (!playableSeries) { + continue; + } + + return { + categoryId: String(category.id), + categoryName: category.title, + items: [ + playableSeries, + ...items.filter( + (item) => + String(item.id ?? '') !== + String(playableSeries.id ?? '') + ), + ], + }; + } + return { categoryId: String(category.id), categoryName: category.title, @@ -246,3 +274,46 @@ function buildStalkerProxyUrl( return `${stalkerMockServer}/stalker?${searchParams.toString()}`; } + +async function findPlayableStalkerSeriesItem( + request: APIRequestContext, + items: StalkerContentItem[] +): Promise { + for (const item of items) { + const itemId = String(item.id ?? '').trim(); + + if (!itemId) { + continue; + } + + const seasonsResponse = await fetchJson< + StalkerProxyPayload< + StalkerOrderedList | StalkerSeasonLike[] + > + >( + request, + buildStalkerProxyUrl('get_ordered_list', { + movie_id: itemId, + p: '1', + type: 'series', + }) + ); + const seasons = extractStalkerSeriesItems(seasonsResponse.payload.js); + + if (seasons.length > 0) { + return item; + } + } + + return null; +} + +function extractStalkerSeriesItems( + payload: StalkerOrderedList | T[] | undefined +): T[] { + if (Array.isArray(payload)) { + return payload; + } + + return payload?.data ?? []; +} diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 44dda9b6f..dbe264c7b 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -4,14 +4,18 @@ import { addXtreamPortal, channelItemByTitle, clickCategoryById, + clickCategoryByNameExact, clickGridListCardByTitle, closeElectronApp, contentCardByTitle, + defaultXtreamPassword, + defaultXtreamUsername, expect, importM3uPlaylistFromNativeDialog, launchElectronApp, openPlaylistFavorites, openPlaylistRecent, + openWorkspaceSection, openSources, resetMockServers, restartElectronApp, @@ -104,9 +108,12 @@ test.describe('Electron Recently Viewed', () => { request, }) => { await resetMockServers(request, ['xtream']); - const liveFixture = await fetchXtreamLiveFixture(request); - const vodFixture = await fetchXtreamVodFixture(request); - const seriesFixture = await fetchXtreamSeriesFixture(request); + const liveFixture = await fetchXtreamLiveFixture(request, xtreamCredentials); + const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials); + const seriesFixture = await fetchXtreamSeriesFixture( + request, + xtreamCredentials + ); const [liveTitle] = pickDistinctTitles(liveFixture.items, getXtreamTitle); const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle); const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getXtreamTitle); @@ -119,20 +126,30 @@ test.describe('Electron Recently Viewed', () => { }); await waitForXtreamWorkspaceReady(app.mainWindow); - await clickCategoryById(app.mainWindow, liveFixture.categoryId); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact( + app.mainWindow, + liveFixture.categoryName + ); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); await channelItemByTitle(app.mainWindow, liveTitle).first().click(); await closeUnifiedLiveDetail(app.mainWindow); await app.mainWindow.getByRole('link', { name: 'Movies', exact: true }).click(); - await clickCategoryById(app.mainWindow, vodFixture.categoryId); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); await clickGridListCardByTitle(app.mainWindow, movieTitle); await playCurrentDetail(app.mainWindow); await goBackFromDetail(app.mainWindow); await app.mainWindow.getByRole('link', { name: 'Series', exact: true }).click(); - await clickCategoryById(app.mainWindow, seriesFixture.categoryId); + await clickCategoryByNameExact( + app.mainWindow, + seriesFixture.categoryName + ); await clickGridListCardByTitle(app.mainWindow, seriesTitle); await playFirstSeriesEpisode(app.mainWindow); @@ -253,6 +270,11 @@ test.describe('Electron Recently Viewed', () => { }); }); +const xtreamCredentials = { + username: defaultXtreamUsername, + password: defaultXtreamPassword, +}; + async function clearRecentItems(page: Page): Promise { await page .getByRole('button', { name: 'Clear recently viewed for this section' }) @@ -282,11 +304,23 @@ async function playCurrentDetail(page: Page): Promise { async function playFirstSeriesEpisode(page: Page): Promise { const seasonCard = page.locator('.season-card').first(); - - await expect(seasonCard).toBeVisible({ timeout: 20000 }); - await seasonCard.click(); const episodeCard = page.locator('.episode-card, .episode-list-item').first(); + await expect + .poll( + async () => + (await seasonCard.count()) + (await episodeCard.count()), + { timeout: 20000 } + ) + .toBeGreaterThan(0); + + if ((await seasonCard.count()) > 0) { + await seasonCard.scrollIntoViewIfNeeded(); + await expect(seasonCard).toBeVisible({ timeout: 20000 }); + await seasonCard.click(); + } + + await episodeCard.scrollIntoViewIfNeeded(); await expect(episodeCard).toBeVisible({ timeout: 20000 }); await episodeCard.click(); } diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 07c99b3ac..966399c6a 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -1,4 +1,4 @@ -import { APIRequestContext, Page } from '@playwright/test'; +import { APIRequestContext, Locator, Page } from '@playwright/test'; import { readFileSync } from 'fs'; import { addStalkerPortal, @@ -281,6 +281,11 @@ test.describe('Electron Workspace Search', () => { username: xtreamSearchUsername, }); await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Movies'); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod$/ + ); await clickCategoryByNameExact(app.mainWindow, sample.categoryName); await expect( @@ -513,6 +518,11 @@ test.describe('Electron Workspace Search', () => { username: xtreamSearchUsername, }); await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Movies'); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod$/ + ); await clickCategoryByNameExact(app.mainWindow, sample.categoryName); await expect( @@ -1176,16 +1186,17 @@ async function clickCategoryByNameExact( page: Page, categoryName: string ): Promise { - const category = page + const categories = page .locator('app-workspace-context-panel .category-item:visible') .filter({ has: page.locator('.nav-item-label', { hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`), }), - }) - .first(); + }); + const category = await pickPreferredCategory(categories); await expect(category).toBeVisible(); + await category.scrollIntoViewIfNeeded(); await category.click(); } @@ -1312,6 +1323,32 @@ async function goBackFromDetail(page: Page): Promise { await backButton.click(); } +async function pickPreferredCategory(categories: Locator): Promise { + const count = await categories.count(); + let fallback = categories.first(); + + for (let index = 0; index < count; index += 1) { + const candidate = categories.nth(index); + + if (!(await candidate.isVisible())) { + continue; + } + + fallback = candidate; + + const countText = + (await candidate.locator('.item-count').first().textContent()) ?? + ''; + const itemCount = Number.parseInt(countText.trim(), 10); + + if (Number.isFinite(itemCount) && itemCount > 0) { + return candidate; + } + } + + return fallback; +} + async function performXtreamPlaylistSearch( page: Page, queries: string[] 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 483a4cdd3..e20c8add6 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -52,6 +52,20 @@ export async function saveCategories( return { success: true }; } + const existingCategories = await db + .select({ count: sql`count(*)` }) + .from(schema.categories) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, type) + ) + ); + + if ((existingCategories[0]?.count ?? 0) > 0) { + return { success: true }; + } + const hiddenSet = new Set(hiddenCategoryXtreamIds || []); const values = categories.map((category) => ({ playlistId, @@ -61,7 +75,16 @@ export async function saveCategories( hidden: hiddenSet.has(category.category_id), })); - await db.insert(schema.categories).values(values); + await db + .insert(schema.categories) + .values(values) + .onConflictDoNothing({ + target: [ + schema.categories.playlistId, + schema.categories.type, + schema.categories.xtreamId, + ], + }); return { success: true }; } 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 d48a4a617..e513f938f 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -219,13 +219,28 @@ export async function saveContent( type: 'live' | 'movie' | 'series', control?: OperationControl ): Promise<{ success: boolean; count: number }> { - console.log( - `>>> DB_SAVE_CONTENT called: ${streams.length} items, type: ${type}, playlist: ${playlistId}` - ); - const dbType = type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live'; + const existingContent = await db + .select({ count: sql`count(*)` }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, dbType), + eq(schema.content.type, type) + ) + ); + + if ((existingContent[0]?.count ?? 0) > 0) { + return { success: true, count: existingContent[0].count }; + } + const categories = await db .select({ id: schema.categories.id, @@ -255,7 +270,16 @@ export async function saveContent( for (let index = 0; index < values.length; index += chunkSize) { await checkpointOperation(control); const chunk = values.slice(index, index + chunkSize); - await db.insert(schema.content).values(chunk); + await db + .insert(schema.content) + .values(chunk) + .onConflictDoNothing({ + target: [ + schema.content.categoryId, + schema.content.type, + schema.content.xtreamId, + ], + }); totalInserted += chunk.length; await reportOperationProgress(control, { phase: 'saving-content',