diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 84238514c..8ab3a5b38 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -9,6 +9,7 @@ import { openWorkspaceSection, refreshSource, resetMockServers, + restartElectronApp, sourceRowByTitle, test, waitForSourceRowIdle, @@ -30,6 +31,12 @@ test.describe('Electron Xtream Category Management', () => { }); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); + // Wait for the route to settle on the live TV section so that + // the sidebar shows live categories (not VOD/series from a + // previous section) before we read from it. + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); const targetCategory = await pickSidebarCategory(app.mainWindow); @@ -56,7 +63,7 @@ test.describe('Electron Xtream Category Management', () => { ).toBeVisible(); await dialog.locator('.search-field input').fill(targetCategory.name); - await toggleManagedCategory(dialog, targetCategory); + await toggleManagedCategory(dialog, targetCategory, false); await dialog.getByRole('button', { name: 'Save', exact: true }).click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', @@ -68,26 +75,43 @@ test.describe('Electron Xtream Category Management', () => { await openSources(app.mainWindow); await refreshSource(app.mainWindow, portalName, { confirm: true }); + await openSources(app.mainWindow); await waitForSourceRowIdle(app.mainWindow, portalName); + + 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 expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount( 0 ); dialog = await openManageCategoriesDialog(app.mainWindow); await dialog.locator('.search-field input').fill(targetCategory.name); - await toggleManagedCategory(dialog, targetCategory); + 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)).toHaveCount( - 1 - ); - await clickCategoryById(app.mainWindow, targetCategory.id); + dialog = await openManageCategoriesDialog(app.mainWindow); + await dialog.locator('.search-field input').fill(targetCategory.name); + const restoredRows = dialog.locator('.category-item'); + await expect(restoredRows).toHaveCount(1, { timeout: 15000 }); + await expect( + restoredRows.first().locator('mat-checkbox input') + ).toBeChecked(); + await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await app.mainWindow.waitForSelector('mat-dialog-container', { + state: 'detached', + }); } finally { await closeElectronApp(app); } @@ -102,6 +126,11 @@ async function openManageCategoriesDialog(page: Page) { await expect( dialog.getByRole('button', { name: 'Select All', exact: true }) ).toBeVisible(); + // Wait for the category list to render — Angular needs a CD cycle after + // isLoading() flips to false before the @for items are painted. + await expect(dialog.locator('.category-item').first()).toBeVisible({ + timeout: 15000, + }); return dialog; } @@ -153,32 +182,25 @@ async function pickSidebarCategory( async function toggleManagedCategory( dialog: Locator, targetCategory: { + id: string; itemCount: number; name: string; - } + }, + shouldBeSelected: boolean ): Promise { - const categories = dialog.locator('.category-item'); - const count = await categories.count(); + void targetCategory.id; + const categoryRows = dialog.locator('.category-item'); + await expect(categoryRows).toHaveCount(1, { timeout: 15000 }); + const categoryRow = categoryRows.first(); + const checkbox = categoryRow.locator('mat-checkbox input'); - 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(); + await expect(categoryRow).toBeVisible({ timeout: 15000 }); + if (shouldBeSelected) { + await checkbox.check(); + await expect(checkbox).toBeChecked(); return; } - throw new Error( - `Could not find category "${targetCategory.name}" with count ${targetCategory.itemCount} in the management dialog.` - ); + await checkbox.uncheck(); + await expect(checkbox).not.toBeChecked(); } diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts new file mode 100644 index 000000000..1497d016a --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -0,0 +1,179 @@ +import { Page } from '@playwright/test'; +import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + goToDashboard, + launchElectronApp, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { + fetchXtreamLiveFixture, + fetchXtreamSeriesFixture, + fetchXtreamVodFixture, + getXtreamTitle, + pickDistinctTitles, +} from './portal-mock-fixtures'; + +test.describe('Dashboard Activation', () => { + test('opens live favorites in the collection route and movies/series in source detail routes from the dashboard', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + 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 app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Xtream Dashboard Activation Source', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + 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 clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); + const movieTitle = await clickFirstGridListCard(app.mainWindow); + await playCurrentDetail(app.mainWindow); + await goBackFromDetail(app.mainWindow); + + await app.mainWindow.getByRole('link', { name: 'Series', exact: true }).click(); + await clickCategoryByNameExact( + app.mainWindow, + seriesFixture.categoryName + ); + const seriesTitle = await clickFirstGridListCard(app.mainWindow); + await playFirstSeriesEpisode(app.mainWindow); + + await goToDashboard(app.mainWindow); + + await dashboardActivityItemByTitle( + app.mainWindow, + 'Global Favorites', + liveTitle + ).click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/favorites$/); + await expect( + app.mainWindow.locator('.player-toolbar .close-btn').first() + ).toBeVisible({ timeout: 20000 }); + + await app.mainWindow.goBack(); + await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); + + await dashboardActivityItemByTitle( + app.mainWindow, + 'Recently Watched', + movieTitle + ).click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + movieTitle + ); + + await app.mainWindow.goBack(); + await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); + + await dashboardActivityItemByTitle( + app.mainWindow, + 'Recently Watched', + seriesTitle + ).click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + seriesTitle + ); + } finally { + await closeElectronApp(app); + } + }); +}); + +const xtreamCredentials = { + username: defaultXtreamUsername, + password: defaultXtreamPassword, +}; + +function dashboardActivityItemByTitle( + page: Page, + widgetTitle: string, + title: string +) { + return page + .locator('.widget-slot') + .filter({ hasText: widgetTitle }) + .first() + .locator('.activity-item') + .filter({ hasText: title }) + .first(); +} + +async function goBackFromDetail(page: Page): Promise { + const backButton = page.locator('app-content-hero .hero__back-button').first(); + + await expect(backButton).toBeVisible({ timeout: 20000 }); + await backButton.click(); +} + +async function playCurrentDetail(page: Page): Promise { + const playButton = page.locator('button.play-btn').first(); + + await expect(playButton).toBeVisible({ timeout: 20000 }); + await playButton.click(); +} + +async function playFirstSeriesEpisode(page: Page): Promise { + const seasonCard = page.locator('.season-card').first(); + 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(); +} + +async function toggleFavoriteForChannel(page: Page, title: string): Promise { + const item = page + .locator('[data-test-id="channel-item"]') + .filter({ hasText: title }) + .first(); + + await expect(item).toBeVisible({ timeout: 20000 }); + await item.hover(); + await item.locator('.favorite-button').first().click(); + await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/); +} diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 276ee88ef..8353aef3d 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -193,7 +193,7 @@ async function waitForAppReady(page: Page): Promise { ); } -export async function openAddPlaylistMenu(page: Page): Promise { +export async function openAddPlaylistDialog(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); } @@ -215,9 +215,9 @@ export async function importM3uPlaylistFromNativeDialog( filePath: string ): Promise { await stubNativePlaylistFileDialog(app.electronApp, filePath); - await openAddPlaylistMenu(app.mainWindow); + await openAddPlaylistDialog(app.mainWindow); await app.mainWindow - .getByRole('menuitem', { name: 'Add via file upload' }) + .locator('mat-dialog-container mat-button-toggle[value="file"]') .click(); await app.mainWindow.locator('mat-dialog-container .file-upload').click(); await app.mainWindow.waitForSelector('mat-dialog-container', { @@ -241,9 +241,9 @@ export async function addXtreamPortal( username = defaultXtreamUsername, } = options; - await openAddPlaylistMenu(page); - await page.getByRole('menuitem', { name: 'Add Xtreme Code' }).click(); + await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container'); + await dialog.locator('mat-button-toggle[value="xtream"]').click(); await setInputValue(dialog.locator('#title'), name); await setInputValue(dialog.locator('#serverUrl'), serverUrl); @@ -298,9 +298,9 @@ export async function addStalkerPortal( portalUrl = `${stalkerMockServer}/portal.php`, } = options; - await openAddPlaylistMenu(page); - await page.getByRole('menuitem', { name: 'Add Stalker Portal' }).click(); + await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container'); + await dialog.locator('mat-button-toggle[value="stalker"]').click(); await setInputValue(dialog.locator('input#title'), name); await setInputValue(dialog.locator('input#portalUrl'), portalUrl); @@ -366,9 +366,9 @@ export async function importM3uPlaylistFromUrl( page: Page, playlistUrl: string ): Promise { - await openAddPlaylistMenu(page); - await page.getByRole('menuitem', { name: 'Add via URL' }).click(); + await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container'); + await dialog.locator('mat-button-toggle[value="url"]').click(); await setInputValue( dialog.locator('input[formcontrolname="playlistUrl"]'), @@ -569,35 +569,22 @@ 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(); + + if (!(await isButtonToggleSelected(toggle))) { + await toggle.click(); + } + + await expect + .poll(() => isButtonToggleSelected(toggle), { + timeout: 10000, + }) + .toBe(true); } export function channelItemByTitle(page: Page, title: string): Locator { @@ -616,6 +603,25 @@ export function contentCardByTitle(page: Page, title: string): Locator { }); } +export async function expectVisibleContentCardTitle( + page: Page, + title: string +): Promise { + await expect + .poll( + async () => { + const titles = await visibleContentCardTitles(page); + return titles.some( + (visibleTitle) => + normalizeVisibleText(visibleTitle) === + normalizeVisibleText(title) + ); + }, + { timeout: 20000 } + ) + .toBe(true); +} + export function gridListCardByTitle(page: Page, title: string): Locator { return page.locator('.category-content-layout mat-card').filter({ has: page.locator('.title', { @@ -624,6 +630,36 @@ export function gridListCardByTitle(page: Page, title: string): Locator { }); } +/** + * Waits for the first grid card to appear (skeleton loading done) and returns + * its display title. Use this instead of picking from fixture order, since the + * grid sorts by date-desc and may paginate items off the first page. + */ +export async function waitForFirstGridListCardTitle(page: Page): Promise { + const card = page.locator('.category-content-layout mat-card').first(); + await expect(card).toBeVisible({ timeout: 20000 }); + return ((await card.locator('.title').textContent()) ?? '').trim(); +} + +/** + * Waits for the first grid card to appear with a non-empty title, clicks it, + * and returns the title. Use this instead of waitForFirstGridListCardTitle + + * clickGridListCardByTitle to avoid a race condition where the grid re-renders + * between the title read and the subsequent search-by-title click. + */ +export async function clickFirstGridListCard(page: Page): Promise { + const card = page.locator('.category-content-layout mat-card').first(); + await expect(card).toBeVisible({ timeout: 20000 }); + const titleEl = card.locator('.title'); + let title = ''; + await expect(async () => { + title = ((await titleEl.textContent()) ?? '').trim(); + expect(title.length).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + await card.click(); + return title; +} + export async function clickGridListCardByTitle( page: Page, title: string @@ -675,9 +711,7 @@ export async function getVisibleSourceTitles(page: Page): Promise { return page.locator('app-playlist-item').evaluateAll((elements) => elements .map((element) => { - const titleElement = element.querySelector( - '[matlistitemtitle], [matListItemTitle], .mdc-list-item__primary-text' - ); + const titleElement = element.querySelector('.playlist-title'); return titleElement?.textContent?.trim() ?? ''; }) .filter((title) => title.length > 0) @@ -709,7 +743,7 @@ export async function dragSourceBefore( targetTitle: string ): Promise { const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon'); - const target = sourceRowByTitle(page, targetTitle).locator('mat-list-item'); + const target = sourceRowByTitle(page, targetTitle).locator('.playlist-item'); await expect(source.first()).toBeVisible(); await expect(target.first()).toBeVisible(); @@ -1344,3 +1378,34 @@ function flexibleTextPattern(value: string): RegExp { 'i' ); } + +async function isButtonToggleSelected(toggle: Locator): Promise { + try { + return await toggle.evaluate((element) => { + const host = element as HTMLElement; + const selectedDescendant = host.querySelector( + '[aria-checked="true"], [aria-pressed="true"]' + ); + + return ( + host.classList.contains('mat-button-toggle-checked') || + host.getAttribute('aria-checked') === 'true' || + host.getAttribute('aria-pressed') === 'true' || + selectedDescendant !== null + ); + }); + } catch { + return false; + } +} + +async function visibleContentCardTitles(page: Page): Promise { + return page + .locator('app-content-card h3') + .allInnerTexts() + .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); +} + +function normalizeVisibleText(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); +} diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index e79b9e07f..ff46bb0d8 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -4,12 +4,14 @@ import { addXtreamPortal, channelItemByTitle, clickCategoryById, - clickCategoryByNameExact, clickGridListCardByTitle, + clickCategoryByNameExact, + clickFirstGridListCard, closeElectronApp, contentCardByTitle, defaultXtreamPassword, defaultXtreamUsername, + expectVisibleContentCardTitle, expect, importM3uPlaylistFromNativeDialog, launchElectronApp, @@ -116,8 +118,6 @@ test.describe('Electron Favorites', () => { xtreamCredentials ); const [liveTitle] = pickDistinctTitles(liveFixture.items, getXtreamTitle); - const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle); - const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getXtreamTitle); const portalTitle = 'Xtream Favorites Source'; const app = await launchElectronApp(dataDir); @@ -139,7 +139,11 @@ test.describe('Electron Favorites', () => { app.mainWindow, vodFixture.categoryName ); - await clickGridListCardByTitle(app.mainWindow, movieTitle); + // Use clickFirstGridListCard: grid sorts by date-desc so fixture order ≠ + // display order; this atomically reads the first card's title and clicks + // it, avoiding a race where the grid re-renders between the title read + // and a separate search-by-title click. + const movieTitle = await clickFirstGridListCard(app.mainWindow); await addCurrentDetailToFavorites(app.mainWindow); await goBackFromDetail(app.mainWindow); @@ -148,7 +152,7 @@ test.describe('Electron Favorites', () => { app.mainWindow, seriesFixture.categoryName ); - await clickGridListCardByTitle(app.mainWindow, seriesTitle); + const seriesTitle = await clickFirstGridListCard(app.mainWindow); await addCurrentDetailToFavorites(app.mainWindow); await goBackFromDetail(app.mainWindow); @@ -156,18 +160,18 @@ test.describe('Electron Favorites', () => { await expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); - await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); - await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); await expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); - await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); - await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); const restarted = await restartElectronApp(app, dataDir); app.electronApp = restarted.electronApp; @@ -186,13 +190,9 @@ test.describe('Electron Favorites', () => { channelItemByTitle(app.mainWindow, liveTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); - await expect( - contentCardByTitle(app.mainWindow, movieTitle) - ).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); - await expect( - contentCardByTitle(app.mainWindow, seriesTitle) - ).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); } finally { await closeElectronApp(app); } @@ -279,6 +279,70 @@ test.describe('Electron Favorites', () => { await closeElectronApp(app); } }); + + test('opens Stalker favorite movies and series in source detail routes and returns back to favorites', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const vodFixture = await fetchStalkerCategoryFixture(request, 'vod'); + const seriesFixture = await fetchStalkerCategoryFixture(request, 'series'); + const [movieTitle] = pickDistinctTitles(vodFixture.items, getStalkerTitle); + const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getStalkerTitle); + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow, { + name: 'Stalker Favorite Detail Source', + }); + await waitForStalkerCatalog(app.mainWindow); + + await app.mainWindow.getByRole('link', { name: 'Movies', exact: true }).click(); + await clickCategoryById(app.mainWindow, vodFixture.categoryId); + 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 clickGridListCardByTitle(app.mainWindow, seriesTitle); + await addCurrentDetailToFavorites(app.mainWindow); + await goBackFromDetail(app.mainWindow); + + await openPlaylistFavorites(app.mainWindow); + + await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); + await contentCardByTitle(app.mainWindow, movieTitle).first().click(); + await app.mainWindow.waitForURL(/\/workspace\/stalker\/[^/]+\/vod\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + movieTitle + ); + + await goBackFromDetail(app.mainWindow); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toMatch(/\/favorites$/); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); + + await switchUnifiedCollectionContent(app.mainWindow, 'Series'); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + await contentCardByTitle(app.mainWindow, seriesTitle).first().click(); + await app.mainWindow.waitForURL(/\/workspace\/stalker\/[^/]+\/series\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + seriesTitle + ); + + await goBackFromDetail(app.mainWindow); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toMatch(/\/favorites$/); + await switchUnifiedCollectionContent(app.mainWindow, 'Series'); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + } finally { + await closeElectronApp(app); + } + }); }); const xtreamCredentials = { diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index dbe264c7b..8b40a5fa0 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -4,12 +4,14 @@ import { addXtreamPortal, channelItemByTitle, clickCategoryById, - clickCategoryByNameExact, clickGridListCardByTitle, + clickCategoryByNameExact, + clickFirstGridListCard, closeElectronApp, contentCardByTitle, defaultXtreamPassword, defaultXtreamUsername, + expectVisibleContentCardTitle, expect, importM3uPlaylistFromNativeDialog, launchElectronApp, @@ -39,6 +41,46 @@ import { } from './portal-mock-fixtures'; test.describe('Electron Recently Viewed', () => { + test('keeps unified live detail open when re-clicking the active M3U recent item', async ({ + dataDir, + }) => { + const filePath = writeTemporaryM3uFile(dataDir, 'm3u-live-reclick.m3u', [ + { + groupTitle: 'Live', + name: 'Stable Recent Channel', + url: 'https://streams.example.test/stable-recent.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, filePath); + await waitForM3uCatalog(app.mainWindow); + + await channelItemByTitle(app.mainWindow, 'Stable Recent Channel') + .first() + .click(); + + await openPlaylistRecent(app.mainWindow); + + const item = channelItemByTitle( + app.mainWindow, + 'Stable Recent Channel' + ).first(); + const closeButton = app.mainWindow + .locator('.player-toolbar .close-btn') + .first(); + + await item.click(); + await expect(closeButton).toBeVisible({ timeout: 20000 }); + + await item.click(); + await expect(closeButton).toBeVisible({ timeout: 20000 }); + } finally { + await closeElectronApp(app); + } + }); + test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists removals after restart', async ({ dataDir, }) => { @@ -115,8 +157,6 @@ test.describe('Electron Recently Viewed', () => { xtreamCredentials ); const [liveTitle] = pickDistinctTitles(liveFixture.items, getXtreamTitle); - const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle); - const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getXtreamTitle); const portalTitle = 'Xtream Recent Source'; const app = await launchElectronApp(dataDir); @@ -141,7 +181,12 @@ test.describe('Electron Recently Viewed', () => { app.mainWindow, vodFixture.categoryName ); - await clickGridListCardByTitle(app.mainWindow, movieTitle); + // Pick from the displayed grid: fixture order ≠ display order (date-desc + // sort + pagination), so the first fixture item may not be on page 1. + // Use clickFirstGridListCard to atomically read + click the first visible + // card, avoiding a race where the grid re-renders between title read and + // a separate search-by-title click. + const movieTitle = await clickFirstGridListCard(app.mainWindow); await playCurrentDetail(app.mainWindow); await goBackFromDetail(app.mainWindow); @@ -150,23 +195,23 @@ test.describe('Electron Recently Viewed', () => { app.mainWindow, seriesFixture.categoryName ); - await clickGridListCardByTitle(app.mainWindow, seriesTitle); + const seriesTitle = await clickFirstGridListCard(app.mainWindow); await playFirstSeriesEpisode(app.mainWindow); await openPlaylistRecent(app.mainWindow); await expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); - await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); - await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); await expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); - await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); - await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); const restarted = await restartElectronApp(app, dataDir); app.electronApp = restarted.electronApp; @@ -180,22 +225,93 @@ test.describe('Electron Recently Viewed', () => { await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); await expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); - await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); - await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1); + 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 expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(0); - await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(0); - await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(0); } finally { await closeElectronApp(app); } }); + test('opens Xtream recent movies and series in source detail routes and returns back to recent', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials); + const seriesFixture = await fetchXtreamSeriesFixture( + request, + xtreamCredentials + ); + const portalTitle = 'Xtream Recent Detail Source'; + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: portalTitle, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await app.mainWindow.getByRole('link', { name: 'Movies', exact: true }).click(); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); + const movieTitle = await clickFirstGridListCard(app.mainWindow); + await playCurrentDetail(app.mainWindow); + await goBackFromDetail(app.mainWindow); + + await app.mainWindow.getByRole('link', { name: 'Series', exact: true }).click(); + await clickCategoryByNameExact( + app.mainWindow, + seriesFixture.categoryName + ); + const seriesTitle = await clickFirstGridListCard(app.mainWindow); + await playFirstSeriesEpisode(app.mainWindow); + + await openPlaylistRecent(app.mainWindow); + + await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); + await contentCardByTitle(app.mainWindow, movieTitle).first().click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + movieTitle + ); + + await goBackFromDetail(app.mainWindow); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toMatch(/\/recent$/); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); + + await switchUnifiedCollectionContent(app.mainWindow, 'Series'); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + await contentCardByTitle(app.mainWindow, seriesTitle).first().click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + seriesTitle + ); + + await goBackFromDetail(app.mainWindow); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toMatch(/\/recent$/); + await switchUnifiedCollectionContent(app.mainWindow, 'Series'); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + } finally { + await closeElectronApp(app); + } + }); + test('tracks Stalker live, movie, and series history across playlist and all-playlists scope, and preserves it after restart', async ({ dataDir, request, diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 47233cff5..2b4fbda65 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -1,14 +1,27 @@ import { Page } from '@playwright/test'; import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, closeElectronApp, createMutableTextServer, enableRemoteControl, expect, launchElectronApp, + openGlobalRecent, openSettings, + openWorkspaceSection, + resetMockServers, + restartElectronApp, saveSettings, test, + waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { + defaultXtreamPassword, + defaultXtreamUsername, +} from './electron-test-fixtures'; +import { fetchXtreamVodFixture } from './portal-mock-fixtures'; const epgFixtureXml = ` @@ -108,6 +121,111 @@ test.describe('Electron Settings', () => { await epgServer.close(); } }); + + test('starts on sources when dashboard is disabled', async ({ dataDir }) => { + const firstLaunch = await launchElectronApp(dataDir); + + try { + await openSettings(firstLaunch.mainWindow); + await firstLaunch.mainWindow + .locator( + 'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]' + ) + .uncheck(); + await saveSettings(firstLaunch.mainWindow); + } finally { + await closeElectronApp(firstLaunch); + } + + const secondLaunch = await launchElectronApp(dataDir); + + try { + await secondLaunch.mainWindow.waitForURL(/\/workspace\/sources$/); + await expect( + secondLaunch.mainWindow.getByRole('link', { + name: 'Dashboard', + exact: true, + }) + ).toHaveCount(0); + await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute( + 'href', + /\/workspace\/sources$/ + ); + } finally { + await closeElectronApp(secondLaunch); + } + }); + + test('restores the last section-level view across restart when configured', async ({ + dataDir, + }) => { + let app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await selectSettingsOption( + app.mainWindow, + 'select-startup-behavior', + 'restore-last-view' + ); + await saveSettings(app.mainWindow); + + await openGlobalRecent(app.mainWindow); + } finally { + app = await restartElectronApp(app, dataDir); + } + + try { + await app.mainWindow.waitForURL(/\/workspace\/global-recent$/); + } finally { + await closeElectronApp(app); + } + }); + + test('ignores settings and restores only the section root after a detail route', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const vodFixture = await fetchXtreamVodFixture(request, { + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }); + let app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await selectSettingsOption( + app.mainWindow, + 'select-startup-behavior', + 'restore-last-view' + ); + await saveSettings(app.mainWindow); + + await addXtreamPortal(app.mainWindow); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await openWorkspaceSection(app.mainWindow, 'Movies'); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); + await clickFirstGridListCard(app.mainWindow); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ + ); + + await openSettings(app.mainWindow); + } finally { + app = await restartElectronApp(app, dataDir); + } + + try { + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod$/); + } finally { + await closeElectronApp(app); + } + }); }); async function selectSettingsOption( diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index c95276a0c..7d91c0907 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -26,6 +26,7 @@ import { waitForM3uCatalog, waitForPortalDebugEvent, waitForXtreamCatalog, + waitForSourceRowIdle, writeTemporaryM3uFile, } from './electron-test-fixtures'; @@ -447,6 +448,13 @@ https://streams.example.test/refreshed-url.m3u8 provider: 'xtream', timeoutMs: 30000, }); + await openSources(app.mainWindow); + await waitForSourceRowIdle(app.mainWindow, 'Refresh Xtream Source'); + await expect( + sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source') + .first() + .locator('.meta') + ).toContainText('Updated:'); } finally { await closeElectronApp(app); await urlServer.close(); diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index c1ab6b8cc..fd621e65c 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -9,6 +9,7 @@ import { test, waitForDbOperationEvent, waitForXtreamCatalog, + xtreamMockServer, } from './electron-test-fixtures'; const stressPortalName = 'Stress Xtream Portal'; @@ -25,6 +26,10 @@ async function openSources(page: Parameters[0]) { await page.waitForURL(/\/workspace\/sources$/); } +function xtreamBlockedStateTitle(page: Parameters[0]) { + return page.locator('.xtream-content-gate .error-view__title'); +} + test.describe('Electron Xtream Responsiveness', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(120000); @@ -52,7 +57,7 @@ test.describe('Electron Xtream Responsiveness', () => { await expect(overlay).toBeVisible({ timeout: 15000 }); await expect( overlay.getByRole('button', { name: /cancel import/i }) - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible({ timeout: 20000 }); await waitForDbOperationEvent(app.mainWindow, { operation: 'save-content', @@ -101,6 +106,139 @@ test.describe('Electron Xtream Responsiveness', () => { } }); + test('stops importing after cancel until the user explicitly retries', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + + const app = await launchElectronApp(dataDir, { + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs, + }, + }); + + try { + await addXtreamPortal(app.mainWindow, { + name: stressPortalName, + password: stressXtreamPassword, + username: stressXtreamUsername, + }); + + const overlay = app.mainWindow.locator('.workspace-loading-overlay'); + const cancelButton = overlay.getByRole('button', { + name: /cancel import/i, + }); + + await expect(overlay).toBeVisible({ timeout: 15000 }); + await expect(cancelButton).toBeVisible({ timeout: 20000 }); + + await waitForDbOperationEvent(app.mainWindow, { + operation: 'save-content', + predicate: (event) => + event.status === 'progress' && + (event.current ?? 0) > 0 && + (event.total ?? 0) > 0, + timeoutMs: 30000, + }); + + await cancelButton.click(); + + await expect(overlay).toHaveCount(0, { timeout: 20000 }); + await expect( + xtreamBlockedStateTitle(app.mainWindow) + ).toHaveText('Import cancelled', { timeout: 20000 }); + + await app.mainWindow.waitForTimeout(1200); + await expect(overlay).toHaveCount(0); + + const retryButton = app.mainWindow.getByRole('button', { + name: 'Retry', + exact: true, + }); + await retryButton.click(); + + await waitForXtreamCatalog(app.mainWindow); + } finally { + await closeElectronApp(app); + } + }); + + test('blocks expired and inactive Xtream accounts before import starts', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Expired Xtream Portal', + password: 'expired', + username: 'expired', + }); + + await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText( + 'Account expired', + { timeout: 20000 } + ); + await expect( + app.mainWindow.locator('.workspace-loading-overlay') + ).toHaveCount(0); + + await openSources(app.mainWindow); + + await addXtreamPortal(app.mainWindow, { + name: 'Inactive Xtream Portal', + password: 'inactive', + username: 'inactive', + }); + + await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText( + 'Account inactive', + { timeout: 20000 } + ); + await expect( + app.mainWindow.locator('.workspace-loading-overlay') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('blocks unreachable Xtream portals immediately without an import loop', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Unavailable Xtream Portal', + password: stressXtreamPassword, + serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'), + username: stressXtreamUsername, + }); + + await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText( + 'Portal unavailable', + { timeout: 20000 } + ); + await expect( + app.mainWindow.locator('.workspace-loading-overlay') + ).toHaveCount(0); + await app.mainWindow.waitForTimeout(1200); + await expect( + app.mainWindow.locator('.workspace-loading-overlay') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + test('keeps the UI responsive during large Xtream playlist deletion', async ({ dataDir, request, diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 2cdfeea21..842e7ae24 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -365,7 +365,7 @@ export function generateSeasons( return Array.from({ length: seasonCount }, (_, s) => { const seasonId = `${series.id}-s${s + 1}`; const episodes = Array.from({ length: episodesPerSeason }, (_, e) => - `${series.id}-s${s + 1}-e${e + 1}` + String(e + 1) ); return { id: seasonId, diff --git a/apps/web-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index d9dac42d6..65e672787 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -12,7 +12,7 @@ test('basic test', async ({ page }) => { // Upload playlist test await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); - await dialog.getByRole('button', { name: 'Add via file upload' }).click(); + await dialog.locator('mat-button-toggle[value="file"]').click(); await page.setInputFiles('input[type="file"]', fixturePath); await page.waitForURL(/\/workspace\/playlists\/.+\/all$/); await expect(page.getByText('test.m3u')).toBeVisible(); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index a12b44148..39e2b90d4 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -76,7 +76,7 @@ async function addStalkerPortal( await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); - await dialog.getByRole('button', { name: 'Stalker' }).click(); + await dialog.locator('mat-button-toggle[value="stalker"]').click(); await setInputValue(dialog.locator('input#title'), name); await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index a7bd97653..7152fa0e9 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -56,7 +56,7 @@ async function addXtreamPortal( await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); - await dialog.getByRole('button', { name: 'Xtream' }).click(); + await dialog.locator('mat-button-toggle[value="xtream"]').click(); await dialog.locator('#title').fill(name); await dialog.locator('#serverUrl').fill(MOCK_SERVER); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index abd266093..0e87e9cb8 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -23,6 +23,7 @@ export interface XtreamPlaylistData { id: string; name: string; title?: string; // Alias for name, used in some templates + updateDate?: number; serverUrl: string; username: string; password: string; diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html index 6ea7fc584..375ef6d6d 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html @@ -55,7 +55,11 @@
@for (category of filteredCategories(); track category.id) { -
+
{ try { const type = this.getDbType(); - const allCategories = await this.dbService.getAllXtreamCategories( - this.data.playlistId, - type - ); + const allCategories = await this.waitForCategories(type); this.categories.set( allCategories.map((c) => ({ ...c, @@ -103,6 +100,30 @@ export class CategoryManagementDialogComponent implements OnInit { } } + private async waitForCategories( + type: 'live' | 'movies' | 'series' + ): Promise { + const retryDeadlineMs = Date.now() + 10000; + const expectedCategoryCount = this.data.itemCounts.size; + + while (true) { + const categories = await this.dbService.getAllXtreamCategories( + this.data.playlistId, + type + ); + + if ( + categories.length > 0 || + expectedCategoryCount === 0 || + Date.now() >= retryDeadlineMs + ) { + return categories; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + clearSearch(): void { this.searchTerm.set(''); } diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index f5a81e1bc..3266fb9a4 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -33,6 +33,7 @@ function toXtreamPlaylistData( id: playlist._id, name: playlist.title || playlist.filename || 'Untitled playlist', title: playlist.title, + updateDate: playlist.updateDate, serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, @@ -51,6 +52,7 @@ export class XtreamWorkspaceRouteSession { private readonly xtreamStore = inject(XtreamStore); private currentPlaylistId: string | null = null; + private currentPlaylistUpdateDate: number | null = null; constructor() { effect(() => { @@ -95,19 +97,22 @@ export class XtreamWorkspaceRouteSession { ? toXtreamPlaylistData(this.playlistContext.activePlaylist()) : null; const currentPlaylist = this.xtreamStore.currentPlaylist(); + const routePlaylistUpdateDate = routePlaylist?.updateDate ?? null; const needsPlaylistBootstrap = Boolean( playlistId && routePlaylist && - currentPlaylist?.id !== playlistId + (currentPlaylist?.id !== playlistId || + this.currentPlaylistUpdateDate !== routePlaylistUpdateDate) ); if ( playlistId && (this.currentPlaylistId !== playlistId || needsPlaylistBootstrap) ) { - if (this.currentPlaylistId !== playlistId) { + if (this.currentPlaylistId !== playlistId || needsPlaylistBootstrap) { this.xtreamStore.resetStore(playlistId); this.currentPlaylistId = playlistId; + this.currentPlaylistUpdateDate = routePlaylistUpdateDate; } this.xtreamStore.setCurrentPlaylist(routePlaylist); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index dbc7ea82c..24dfde787 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -3,7 +3,9 @@ @for (item of items(); track $index) {