test(e2e): enhance Xtream category management and sorting with unique title handling

This commit is contained in:
4gray committed 2026-03-29 13:37:48 +02:00
1 parent 654e63de27
commit 3a66bf21a3
9 files changed
+501 -108

No files matched your search

@@ -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<void> {
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<void> {
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<string[]> {
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<string[]> {
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<string>();
return titles.filter((title) => {
if (seen.has(title)) {
return false;
}
seen.add(title);
return true;
});
}
@@ -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<string, number>();
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<void> {
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.`
);
}
@@ -339,7 +339,12 @@ export async function saveSettings(page: Page): Promise<void> {
}
export async function goToDashboard(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
export async function waitForXtreamCatalog(page: Page): Promise<void> {
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<void> {
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<void> {
});
}
async function waitForXtreamCategoryCounts(page: Page): Promise<void> {
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<void> {
const overlay = page.locator('.workspace-loading-overlay');
@@ -931,6 +953,32 @@ export async function waitForXtreamImportToFinish(page: Page): Promise<void> {
await expect(overlay).toHaveCount(0, { timeout: 30000 });
}
async function pickPreferredCategory(categories: Locator): Promise<Locator> {
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<void> {
await page.waitForURL(/\/workspace\/stalker\/.+/);
+28 -6
View File
@@ -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<void> {
const addButton = page.locator('button.favorite-btn').first();
@@ -55,6 +55,10 @@ type StalkerOrderedList<T> = {
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<StalkerContentItem | null> {
for (const item of items) {
const itemId = String(item.id ?? '').trim();
if (!itemId) {
continue;
}
const seasonsResponse = await fetchJson<
StalkerProxyPayload<
StalkerOrderedList<StalkerSeasonLike> | 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<T>(
payload: StalkerOrderedList<T> | T[] | undefined
): T[] {
if (Array.isArray(payload)) {
return payload;
}
return payload?.data ?? [];
}
+43 -9
View File
@@ -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<void> {
await page
.getByRole('button', { name: 'Clear recently viewed for this section' })
@@ -282,11 +304,23 @@ async function playCurrentDetail(page: Page): Promise<void> {
async function playFirstSeriesEpisode(page: Page): Promise<void> {
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();
}
+41 -4
View File
@@ -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<void> {
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<void> {
await backButton.click();
}
async function pickPreferredCategory(categories: Locator): Promise<Locator> {
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[]
@@ -52,6 +52,20 @@ export async function saveCategories(
return { success: true };
}
const existingCategories = await db
.select({ count: sql<number>`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 };
}
@@ -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<number>`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',