diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index a7696d3a9..c4b9c8a50 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -48,9 +48,9 @@ test.describe('Electron Xtream Category Management', () => { exact: true, }) .click(); - await expect(dialog.locator('.category-item mat-checkbox input:checked')).toHaveCount( - 0 - ); + await expect( + dialog.locator('.category-item mat-checkbox input:checked') + ).toHaveCount(0); await dialog .getByRole('button', { @@ -59,19 +59,25 @@ test.describe('Electron Xtream Category Management', () => { }) .click(); await expect( - dialog.locator('.category-item mat-checkbox input:checked').first() + dialog + .locator('.category-item mat-checkbox input:checked') + .first() ).toBeVisible(); - await dialog.locator('.search-field input').fill(targetCategory.name); + await dialog + .locator('input[type="search"]') + .fill(targetCategory.name); await toggleManagedCategory(dialog, targetCategory, false); - await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + 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( - 0 - ); + await expect( + sidebarCategoryById(app.mainWindow, targetCategory.id) + ).toHaveCount(0); await openSources(app.mainWindow); await refreshSource(app.mainWindow, portalName, { confirm: true }); @@ -89,26 +95,34 @@ test.describe('Electron Xtream Category Management', () => { await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/live/ ); - await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount( - 0 - ); + await expect( + sidebarCategoryById(app.mainWindow, targetCategory.id) + ).toHaveCount(0); dialog = await openManageCategoriesDialog(app.mainWindow); - await dialog.locator('.search-field input').fill(targetCategory.name); + await dialog + .locator('input[type="search"]') + .fill(targetCategory.name); await toggleManagedCategory(dialog, targetCategory, true); - await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Save', exact: true }) + .click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', }); dialog = await openManageCategoriesDialog(app.mainWindow); - await dialog.locator('.search-field input').fill(targetCategory.name); + await dialog + .locator('input[type="search"]') + .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 dialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', }); @@ -148,9 +162,13 @@ test.describe('Electron Xtream Category Management', () => { dialog.locator('.category-item mat-checkbox input:checked') ).toHaveCount(0); - await dialog.locator('.search-field input').fill(targetCategory.name); + await dialog + .locator('input[type="search"]') + .fill(targetCategory.name); await toggleManagedCategory(dialog, targetCategory, true); - await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Save', exact: true }) + .click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', }); @@ -165,8 +183,8 @@ test.describe('Electron Xtream Category Management', () => { await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/live/ ); - await expectVisibleSidebarCategoryIds(app.mainWindow, [ - targetCategory.id, + await expectVisibleSidebarCategoryNames(app.mainWindow, [ + targetCategory.name, ]); const restarted = await restartElectronApp(app, dataDir); @@ -180,8 +198,8 @@ test.describe('Electron Xtream Category Management', () => { await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/live/ ); - await expectVisibleSidebarCategoryIds(app.mainWindow, [ - targetCategory.id, + await expectVisibleSidebarCategoryNames(app.mainWindow, [ + targetCategory.name, ]); } finally { await closeElectronApp(app); @@ -245,43 +263,102 @@ async function expectVisibleSidebarCategoryIds( expect(actualIds).toEqual(expectedIds); } +async function expectVisibleSidebarCategoryNames( + page: Page, + expectedNames: string[] +): Promise { + await expect + .poll(async () => { + const categories = page.locator( + 'app-workspace-context-panel .category-item:visible' + ); + const actualNames: string[] = []; + const count = await categories.count(); + + for (let index = 0; index < count; index += 1) { + const categoryName = + ( + await categories + .nth(index) + .locator('.nav-item-label') + .textContent() + )?.trim() ?? ''; + + if (categoryName) { + actualNames.push(categoryName); + } + } + + return actualNames; + }) + .toEqual(expectedNames); +} + 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 }> = []; + let preferredCandidate: + | { id: string; itemCount: number; name: string } + | null = null; - 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; + await expect + .poll(async () => { + 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; + }> = []; - if (id && name && itemCount > 0) { - candidates.push({ id, itemCount, name }); - } - } + 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; - const nameCounts = new Map(); - for (const candidate of candidates) { - nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1); - } + if (id && name && itemCount > 0) { + candidates.push({ id, itemCount, name }); + } + } - const preferredCandidate = - candidates.find((candidate) => nameCounts.get(candidate.name) === 1) ?? - candidates[0]; + if (candidates.length === 0) { + preferredCandidate = null; + return false; + } - if (!preferredCandidate) { - throw new Error('No visible Xtream category with content was found.'); - } + const nameCounts = new Map(); + for (const candidate of candidates) { + nameCounts.set( + candidate.name, + (nameCounts.get(candidate.name) ?? 0) + 1 + ); + } - return preferredCandidate; + preferredCandidate = + candidates.find( + (candidate) => nameCounts.get(candidate.name) === 1 + ) ?? candidates[0]; + + return preferredCandidate !== null; + }) + .toBe(true, { + message: 'No visible Xtream category with content was found.', + timeout: 15000, + }); + + return preferredCandidate!; } async function toggleManagedCategory( diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 1497d016a..0c2422e68 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -28,13 +28,22 @@ test.describe('Dashboard Activation', () => { request, }) => { await resetMockServers(request, ['xtream']); - const liveFixture = await fetchXtreamLiveFixture(request, xtreamCredentials); - const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials); + 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 [liveTitle] = pickDistinctTitles( + liveFixture.items, + getXtreamTitle + ); const app = await launchElectronApp(dataDir); try { @@ -50,7 +59,9 @@ test.describe('Dashboard Activation', () => { ); await toggleFavoriteForChannel(app.mainWindow, liveTitle); - await app.mainWindow.getByRole('link', { name: 'Movies', exact: true }).click(); + await app.mainWindow + .getByRole('link', { name: 'Movies', exact: true }) + .click(); await clickCategoryByNameExact( app.mainWindow, vodFixture.categoryName @@ -59,7 +70,9 @@ test.describe('Dashboard Activation', () => { await playCurrentDetail(app.mainWindow); await goBackFromDetail(app.mainWindow); - await app.mainWindow.getByRole('link', { name: 'Series', exact: true }).click(); + await app.mainWindow + .getByRole('link', { name: 'Series', exact: true }) + .click(); await clickCategoryByNameExact( app.mainWindow, seriesFixture.categoryName @@ -74,7 +87,9 @@ test.describe('Dashboard Activation', () => { 'Global Favorites', liveTitle ).click(); - await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/favorites$/); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/favorites$/ + ); await expect( app.mainWindow.locator('.player-toolbar .close-btn').first() ).toBeVisible({ timeout: 20000 }); @@ -87,10 +102,12 @@ test.describe('Dashboard Activation', () => { 'Recently Watched', movieTitle ).click(); - await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/); - await expect(app.mainWindow.locator('app-content-hero')).toContainText( - movieTitle + 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$/); @@ -100,10 +117,12 @@ test.describe('Dashboard Activation', () => { 'Recently Watched', seriesTitle ).click(); - await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/); - await expect(app.mainWindow.locator('app-content-hero')).toContainText( - seriesTitle + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/ ); + await expect( + app.mainWindow.locator('app-content-hero') + ).toContainText(seriesTitle); } finally { await closeElectronApp(app); } @@ -130,7 +149,9 @@ function dashboardActivityItemByTitle( } async function goBackFromDetail(page: Page): Promise { - const backButton = page.locator('app-content-hero .hero__back-button').first(); + const backButton = page + .locator('app-content-hero .hero__back-button') + .first(); await expect(backButton).toBeVisible({ timeout: 20000 }); await backButton.click(); @@ -145,7 +166,9 @@ async function playCurrentDetail(page: Page): Promise { async function playFirstSeriesEpisode(page: Page): Promise { const seasonCard = page.locator('.season-card').first(); - const episodeCard = page.locator('.episode-card, .episode-list-item').first(); + const episodeCard = page + .locator('.episode-card, .episode-list-item') + .first(); await expect .poll( @@ -166,7 +189,10 @@ async function playFirstSeriesEpisode(page: Page): Promise { await episodeCard.click(); } -async function toggleFavoriteForChannel(page: Page, title: string): Promise { +async function toggleFavoriteForChannel( + page: Page, + title: string +): Promise { const item = page .locator('[data-test-id="channel-item"]') .filter({ hasText: title }) @@ -175,5 +201,7 @@ async function toggleFavoriteForChannel(page: Page, title: string): Promise { + const pathname = new URL(page.url()).pathname; + const isSelected = + (await category.first().getAttribute('aria-current')) === + 'true'; + + return ( + isSelected || + pathname.endsWith(`/${categoryId}`) || + pathname.includes(`/${categoryId}/`) + ); + }) + .toBe(true); } export async function clickCategoryByNameExact( @@ -698,7 +712,23 @@ export async function clickCategoryByNameExact( await expect(category).toBeVisible(); await category.scrollIntoViewIfNeeded(); + const categoryId = + (await category.getAttribute('data-category-id'))?.trim() ?? ''; await category.click(); + await expect + .poll(async () => { + const pathname = new URL(page.url()).pathname; + const isSelected = + (await category.getAttribute('aria-current')) === 'true'; + + return ( + isSelected || + (categoryId.length > 0 && + (pathname.endsWith(`/${categoryId}`) || + pathname.includes(`/${categoryId}/`))) + ); + }) + .toBe(true); } export function sourceRowByTitle(page: Page, title: string): Locator { diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts new file mode 100644 index 000000000..ca8cffcb7 --- /dev/null +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -0,0 +1,503 @@ +import { Page } from '@playwright/test'; +import { + channelItemByTitle, + addStalkerPortal, + addXtreamPortal, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + openPlaylistFavorites, + openPlaylistRecent, + openSettings, + openSourceEditor, + openSources, + openWorkspaceSection, + resetMockServers, + restartElectronApp, + saveSettings, + saveSourceDialog, + sourceRowByTitle, + test, + updateSourceDialog, + waitForDbOperationEvent, + waitForM3uCatalog, + waitForStalkerCatalog, + waitForXtreamCatalog, + writeTemporaryM3uFile, + xtreamMockServer, +} from './electron-test-fixtures'; + +const xtreamStressUsername = 'stress'; +const xtreamStressPassword = 'stress'; +const dbWorkerBatchDelayMs = '20'; + +const localAFileName = 'switcher-local-a.m3u'; +const localADisplayName = 'switcher-local-a'; +const localBFileName = 'switcher-local-b.m3u'; +const localBDisplayName = 'switcher-local-b'; +const localAChannelName = 'Switcher Favorite A'; +const localBChannelName = 'Switcher Favorite B'; +const offlineLocalFileName = 'offline-local-source.m3u'; +const offlineLocalDisplayName = 'offline-local-source'; +const xtreamAName = 'Switcher Xtream A'; +const xtreamBName = 'Switcher Xtream B'; +const stalkerAName = 'Switcher Stalker A'; +const stalkerBName = 'Switcher Stalker B'; +const offlineXtreamName = 'Offline Cached Xtream'; +const importingXtreamName = 'Importing Xtream Portal'; + +test.describe('Electron Playlist Switcher', () => { + test.describe.configure({ mode: 'serial' }); + test.setTimeout(120000); + + test('preserves same-type sections and cross-type mappings when switching from the workspace header', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream', 'stalker']); + const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ + { + groupTitle: 'News', + name: 'Switcher Local A', + url: 'https://streams.example.test/switcher-local-a.m3u8', + }, + ]); + const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [ + { + groupTitle: 'Sports', + name: 'Switcher Local B', + url: 'https://streams.example.test/switcher-local-b.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, localAPath); + await importM3uPlaylistFromNativeDialog(app, localBPath); + await addXtreamPortal(app.mainWindow, { + name: xtreamAName, + }); + await addXtreamPortal(app.mainWindow, { + name: xtreamBName, + }); + await addStalkerPortal(app.mainWindow, { + name: stalkerAName, + }); + await addStalkerPortal(app.mainWindow, { + name: stalkerBName, + }); + await openSources(app.mainWindow); + + await sourceRowByTitle(app.mainWindow, localADisplayName) + .first() + .click(); + await waitForM3uCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Recently viewed'); + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/recent$/ + ); + + await switchPlaylistFromHeader(app.mainWindow, localBDisplayName); + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/recent$/ + ); + + await switchPlaylistFromHeader(app.mainWindow, xtreamAName); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/recent$/ + ); + + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await waitForXtreamCatalog(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live$/ + ); + + await switchPlaylistFromHeader(app.mainWindow, xtreamBName); + await waitForXtreamCatalog(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live$/ + ); + + await openWorkspaceSection(app.mainWindow, 'Recently added'); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/recently-added$/ + ); + + await switchPlaylistFromHeader(app.mainWindow, stalkerAName); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/recent$/ + ); + + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await waitForStalkerCatalog(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/itv$/ + ); + + await switchPlaylistFromHeader(app.mainWindow, stalkerBName); + await waitForStalkerCatalog(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/itv$/ + ); + + await switchPlaylistFromHeader(app.mainWindow, xtreamAName); + await waitForXtreamCatalog(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live$/ + ); + + await switchPlaylistFromHeader(app.mainWindow, localADisplayName); + await waitForM3uCatalog(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/all$/ + ); + + await openWorkspaceSection(app.mainWindow, 'Favorites'); + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/favorites$/ + ); + + await switchPlaylistFromHeader(app.mainWindow, stalkerBName); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/favorites$/ + ); + } finally { + await closeElectronApp(app); + } + }); + + test('refreshes M3U unified collections immediately when switching same-type playlists from the header', async ({ + dataDir, + }) => { + const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ + { + groupTitle: 'News', + name: localAChannelName, + url: 'https://streams.example.test/switcher-favorite-a.m3u8', + }, + ]); + const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [ + { + groupTitle: 'Sports', + name: localBChannelName, + url: 'https://streams.example.test/switcher-favorite-b.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, localAPath); + await importM3uPlaylistFromNativeDialog(app, localBPath); + + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, localADisplayName) + .first() + .click(); + await waitForM3uCatalog(app.mainWindow); + await toggleFavoriteForChannel(app.mainWindow, localAChannelName); + await channelItemByTitle(app.mainWindow, localAChannelName) + .first() + .click(); + + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, localBDisplayName) + .first() + .click(); + await waitForM3uCatalog(app.mainWindow); + await toggleFavoriteForChannel(app.mainWindow, localBChannelName); + await channelItemByTitle(app.mainWindow, localBChannelName) + .first() + .click(); + + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, localADisplayName) + .first() + .click(); + await waitForM3uCatalog(app.mainWindow); + + await openPlaylistFavorites(app.mainWindow); + await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( + 1 + ); + await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( + 0 + ); + + await switchPlaylistFromHeader(app.mainWindow, localBDisplayName); + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/favorites$/ + ); + await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( + 1 + ); + await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( + 0 + ); + + await openPlaylistRecent(app.mainWindow); + await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( + 1 + ); + await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( + 0 + ); + + await switchPlaylistFromHeader(app.mainWindow, localADisplayName); + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/recent$/ + ); + await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( + 1 + ); + await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( + 0 + ); + } finally { + await closeElectronApp(app); + } + }); + + test('switches away from importing Xtream playlists, returns before completion, and avoids redundant reimport after caching', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ + { + groupTitle: 'News', + name: 'Import Switch Local', + url: 'https://streams.example.test/import-switch-local.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir, { + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs, + }, + }); + + try { + await importM3uPlaylistFromNativeDialog(app, localAPath); + await waitForM3uCatalog(app.mainWindow); + + await addXtreamPortal(app.mainWindow, { + name: importingXtreamName, + password: xtreamStressPassword, + username: xtreamStressUsername, + }); + + const overlay = app.mainWindow.locator('.workspace-loading-overlay'); + await expect(overlay).toBeVisible({ timeout: 15000 }); + await waitForDbOperationEvent(app.mainWindow, { + operation: 'save-content', + predicate: (event) => + event.status === 'progress' && + (event.current ?? 0) > 0 && + (event.total ?? 0) > 0, + timeoutMs: 30000, + }); + + await switchPlaylistFromHeader(app.mainWindow, localADisplayName); + await waitForM3uCatalog(app.mainWindow); + await expect(overlay).toHaveCount(0); + + await switchPlaylistFromHeader(app.mainWindow, importingXtreamName); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod$/ + ); + await Promise.race([ + overlay.waitFor({ state: 'visible', timeout: 15000 }), + waitForXtreamCatalog(app.mainWindow), + ]); + + await waitForXtreamCatalog(app.mainWindow); + + await switchPlaylistFromHeader(app.mainWindow, localADisplayName); + await waitForM3uCatalog(app.mainWindow); + + const startedBeforeReturn = await countDbEvents( + app.mainWindow, + 'save-content', + 'started' + ); + + await switchPlaylistFromHeader(app.mainWindow, importingXtreamName); + await waitForXtreamCatalog(app.mainWindow); + await expect(overlay).toHaveCount(0); + + await app.mainWindow.waitForTimeout(1200); + + const startedAfterReturn = await countDbEvents( + app.mainWindow, + 'save-content', + 'started' + ); + expect(startedAfterReturn).toBe(startedBeforeReturn); + } finally { + await closeElectronApp(app); + } + }); + + test('keeps cached Xtream content browseable with an unavailable warning after the portal goes offline and after restart', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const offlineLocalPath = writeTemporaryM3uFile( + dataDir, + offlineLocalFileName, + [ + { + groupTitle: 'News', + name: 'Offline Local Channel', + url: 'https://streams.example.test/offline-local.m3u8', + }, + ] + ); + 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, { + name: offlineXtreamName, + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }); + await openWorkspaceSection(app.mainWindow, 'Movies'); + await waitForXtreamCatalog(app.mainWindow); + + await importM3uPlaylistFromNativeDialog(app, offlineLocalPath); + await waitForM3uCatalog(app.mainWindow); + + await openSources(app.mainWindow); + const dialog = await openSourceEditor( + app.mainWindow, + offlineXtreamName + ); + await updateSourceDialog(dialog, { + serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'), + }); + await saveSourceDialog(app.mainWindow, dialog); + + await sourceRowByTitle(app.mainWindow, offlineLocalDisplayName) + .first() + .click(); + await waitForM3uCatalog(app.mainWindow); + + await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName); + await waitForXtreamCatalog(app.mainWindow); + await expectXtreamUnavailableWarning(app.mainWindow); + await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( + 0 + ); + + app = await restartElectronApp(app, dataDir); + + await waitForXtreamCatalog(app.mainWindow); + await expectXtreamUnavailableWarning(app.mainWindow); + await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( + 0 + ); + } finally { + await closeElectronApp(app); + } + }); +}); + +async function openPlaylistSwitcher(page: Page): Promise { + await page + .locator('app-playlist-switcher .playlist-switcher-trigger') + .click(); + await expect( + page.locator('.cdk-overlay-pane .playlist-item').first() + ).toBeVisible(); +} + +async function switchPlaylistFromHeader( + page: Page, + title: string +): Promise { + await openPlaylistSwitcher(page); + const item = page + .locator('.cdk-overlay-pane .playlist-item') + .filter({ hasText: title }) + .first(); + + await expect(item).toBeVisible(); + await item.click(); + await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount( + 0 + ); +} + +async function expectPathname(page: Page, pattern: RegExp): Promise { + await expect + .poll(() => new URL(page.url()).pathname, { timeout: 20000 }) + .toMatch(pattern); +} + +async function countDbEvents( + page: Page, + operation: string, + status: string +): Promise { + return page.evaluate( + ({ operation, status: desiredStatus }) => + (window.__dbOperationEvents ?? []).filter( + (event) => + event.operation === operation && + event.status === desiredStatus + ).length, + { operation, status } + ); +} + +async function selectSettingsOption( + page: Page, + selectTestId: string, + optionTestId: string +): Promise { + await page.getByTestId(selectTestId).click(); + await page.getByTestId(optionTestId).click(); +} + +async function expectXtreamUnavailableWarning(page: Page): Promise { + await expect( + page.getByTestId('xtream-offline-warning') + ).toBeVisible({ timeout: 20000 }); +} + +async function toggleFavoriteForChannel(page: Page, title: string): Promise { + const item = channelItemByTitle(page, 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/portal-mock-fixtures.ts b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts index 92153c111..cf53230db 100644 --- a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts +++ b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts @@ -14,8 +14,12 @@ export type XtreamCategory = { export type XtreamLiveStream = { added?: string; category_id?: string; + epg_channel_id?: string; name?: string; stream_id?: number | string; + stream_type?: string; + tv_archive?: number | string; + tv_archive_duration?: number | string; }; export type XtreamVodStream = { @@ -40,6 +44,44 @@ export type XtreamCategoryFixture = { items: T[]; }; +export type XtreamRawEpgListing = { + channel_id?: string; + description?: string; + end?: string; + epg_id?: string; + id?: string; + lang?: string; + start?: string; + start_timestamp?: string; + stop_timestamp?: string; + title?: string; +}; + +export type XtreamNormalizedEpgListing = { + channelId: string; + description: string; + end: string; + id: string; + rawDescription: string; + rawEnd: string; + rawStart: string; + rawTitle: string; + start: string; + startTimestamp: number; + stopTimestamp: number; + title: string; +}; + +export type XtreamEpgFixture = { + categoryId: string; + categoryName: string; + fullEpg: XtreamNormalizedEpgListing[]; + rawFullEpg: XtreamRawEpgListing[]; + rawShortEpg: XtreamRawEpgListing[]; + shortEpg: XtreamNormalizedEpgListing[]; + stream: XtreamLiveStream; +}; + type StalkerCategory = { id: string; title: string; @@ -104,6 +146,60 @@ export async function fetchXtreamSeriesFixture( }); } +export async function fetchXtreamEpgFixture( + request: APIRequestContext, + credentials = { username: 'epg', password: 'epg' } +): Promise { + const liveFixture = await fetchXtreamLiveFixture(request, credentials); + const stream = liveFixture.items[0]; + if (!stream) { + throw new Error('Xtream EPG fixture returned no live stream.'); + } + const streamId = Number(stream.stream_id); + + if (!Number.isFinite(streamId) || streamId <= 0) { + throw new Error('Xtream EPG fixture returned an invalid stream id.'); + } + + const rawShortResponse = await fetchJson<{ + epg_listings?: XtreamRawEpgListing[]; + }>( + request, + `${xtreamMockServer}/player_api.php?action=get_short_epg&username=${encodeURIComponent( + credentials.username + )}&password=${encodeURIComponent( + credentials.password + )}&stream_id=${streamId}&limit=3` + ); + const rawFullResponse = await fetchJson<{ + epg_listings?: XtreamRawEpgListing[]; + }>( + request, + `${xtreamMockServer}/player_api.php?action=get_simple_data_table&username=${encodeURIComponent( + credentials.username + )}&password=${encodeURIComponent( + credentials.password + )}&stream_id=${streamId}` + ); + + const rawShortEpg = rawShortResponse.epg_listings ?? []; + const rawFullEpg = rawFullResponse.epg_listings ?? []; + + if (rawShortEpg.length === 0 || rawFullEpg.length === 0) { + throw new Error('Xtream EPG fixture returned no schedule data.'); + } + + return { + categoryId: liveFixture.categoryId, + categoryName: liveFixture.categoryName, + stream, + rawShortEpg, + rawFullEpg, + shortEpg: normalizeXtreamEpgListings(rawShortEpg), + fullEpg: normalizeXtreamEpgListings(rawFullEpg), + }; +} + export async function fetchStalkerCategoryFixture( request: APIRequestContext, type: 'itv' | 'series' | 'vod' @@ -261,6 +357,44 @@ async function fetchJson( return (await response.json()) as T; } +function normalizeXtreamEpgListings( + listings: XtreamRawEpgListing[] +): XtreamNormalizedEpgListing[] { + return listings + .map((listing, index) => { + const startTimestamp = Number.parseInt( + String(listing.start_timestamp ?? ''), + 10 + ); + const stopTimestamp = Number.parseInt( + String(listing.stop_timestamp ?? ''), + 10 + ); + + return { + id: String(listing.id ?? index), + title: decodeXtreamText(String(listing.title ?? '')), + description: decodeXtreamText( + String(listing.description ?? '') + ), + rawTitle: String(listing.title ?? ''), + rawDescription: String(listing.description ?? ''), + rawStart: String(listing.start ?? ''), + rawEnd: String(listing.end ?? ''), + start: new Date(startTimestamp * 1000).toISOString(), + end: new Date(stopTimestamp * 1000).toISOString(), + startTimestamp, + stopTimestamp, + channelId: String(listing.channel_id ?? listing.epg_id ?? ''), + }; + }) + .sort((left, right) => left.startTimestamp - right.startTimestamp); +} + +function decodeXtreamText(value: string): string { + return Buffer.from(value, 'base64').toString('utf-8'); +} + function buildStalkerProxyUrl( action: string, params: Record diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 966399c6a..7f04cf703 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -1180,6 +1180,20 @@ async function clickCategoryById( await expect(category.first()).toBeVisible(); await category.first().click(); + await expect + .poll(async () => { + const pathname = new URL(page.url()).pathname; + const isSelected = + (await category.first().getAttribute('aria-current')) === + 'true'; + + return ( + isSelected || + pathname.endsWith(`/${categoryId}`) || + pathname.includes(`/${categoryId}/`) + ); + }) + .toBe(true); } async function clickCategoryByNameExact( @@ -1197,7 +1211,23 @@ async function clickCategoryByNameExact( await expect(category).toBeVisible(); await category.scrollIntoViewIfNeeded(); + const categoryId = + (await category.getAttribute('data-category-id'))?.trim() ?? ''; await category.click(); + await expect + .poll(async () => { + const pathname = new URL(page.url()).pathname; + const isSelected = + (await category.getAttribute('aria-current')) === 'true'; + + return ( + isSelected || + (categoryId.length > 0 && + (pathname.endsWith(`/${categoryId}`) || + pathname.includes(`/${categoryId}/`))) + ); + }) + .toBe(true); } async function openWorkspaceSection(page: Page, label: string): Promise { diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 7d91c0907..2deeb8b40 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -45,13 +45,17 @@ test.describe('Electron Sources View', () => { request, }) => { await resetMockServers(request, ['xtream', 'stalker']); - const localFilePath = writeTemporaryM3uFile(dataDir, localSourceFileName, [ - { - groupTitle: 'News', - name: 'Alpha Local News', - url: 'https://streams.example.test/local-alpha.m3u8', - }, - ]); + const localFilePath = writeTemporaryM3uFile( + dataDir, + localSourceFileName, + [ + { + groupTitle: 'News', + name: 'Alpha Local News', + url: 'https://streams.example.test/local-alpha.m3u8', + }, + ] + ); const urlServer = await createMutableTextServer( `#EXTM3U #EXTINF:-1 group-title="Sports",Omega URL Sports @@ -67,7 +71,10 @@ https://streams.example.test/url-omega.m3u8 try { await importM3uPlaylistFromNativeDialog(app, localFilePath); - await importM3uPlaylistFromUrl(app.mainWindow, urlServer.resourceUrl); + await importM3uPlaylistFromUrl( + app.mainWindow, + urlServer.resourceUrl + ); await addXtreamPortal(app.mainWindow, { name: xtreamSourceName, }); @@ -223,7 +230,10 @@ https://streams.example.test/url-omega.m3u8 }); await saveSourceDialog(app.mainWindow, dialog); - dialog = await openSourceEditor(app.mainWindow, 'Editable Xtream Source'); + dialog = await openSourceEditor( + app.mainWindow, + 'Editable Xtream Source' + ); await updateSourceDialog(dialog, { password: 'pass1', serverUrl: 'http://127.0.0.1:3211', @@ -232,7 +242,10 @@ https://streams.example.test/url-omega.m3u8 }); await saveSourceDialog(app.mainWindow, dialog); - dialog = await openSourceEditor(app.mainWindow, 'Editable Stalker Source'); + dialog = await openSourceEditor( + app.mainWindow, + 'Editable Stalker Source' + ); await updateSourceDialog(dialog, { macAddress: '00:1A:79:00:00:99', portalUrl: 'http://127.0.0.1:3210/portal.php', @@ -240,30 +253,45 @@ https://streams.example.test/url-omega.m3u8 }); await saveSourceDialog(app.mainWindow, dialog); - dialog = await openSourceEditor(app.mainWindow, 'Edited Local Source'); + dialog = await openSourceEditor( + app.mainWindow, + 'Edited Local Source' + ); await expectSourceDialogValues(dialog, { autoRefresh: true, title: 'Edited Local Source', userAgent: 'Electron E2E Local/1.0', }); - await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); - dialog = await openSourceEditor(app.mainWindow, 'Edited Xtream Source'); + dialog = await openSourceEditor( + app.mainWindow, + 'Edited Xtream Source' + ); await expectSourceDialogValues(dialog, { password: 'pass1', serverUrl: 'http://127.0.0.1:3211', title: 'Edited Xtream Source', username: 'user1', }); - await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); - dialog = await openSourceEditor(app.mainWindow, 'Edited Stalker Source'); + dialog = await openSourceEditor( + app.mainWindow, + 'Edited Stalker Source' + ); await expectSourceDialogValues(dialog, { macAddress: '00:1A:79:00:00:99', portalUrl: 'http://127.0.0.1:3210/portal.php', title: 'Edited Stalker Source', }); - await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); const restarted = await restartElectronApp(app, dataDir); app.electronApp = restarted.electronApp; @@ -271,24 +299,37 @@ https://streams.example.test/url-omega.m3u8 await openSources(app.mainWindow); - dialog = await openSourceEditor(app.mainWindow, 'Edited Local Source'); + dialog = await openSourceEditor( + app.mainWindow, + 'Edited Local Source' + ); await expectSourceDialogValues(dialog, { autoRefresh: true, title: 'Edited Local Source', userAgent: 'Electron E2E Local/1.0', }); - await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); - dialog = await openSourceEditor(app.mainWindow, 'Edited Xtream Source'); + dialog = await openSourceEditor( + app.mainWindow, + 'Edited Xtream Source' + ); await expectSourceDialogValues(dialog, { password: 'pass1', serverUrl: 'http://127.0.0.1:3211', title: 'Edited Xtream Source', username: 'user1', }); - await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); - dialog = await openSourceEditor(app.mainWindow, 'Edited Stalker Source'); + dialog = await openSourceEditor( + app.mainWindow, + 'Edited Stalker Source' + ); await expectSourceDialogValues(dialog, { macAddress: '00:1A:79:00:00:99', portalUrl: 'http://127.0.0.1:3210/portal.php', @@ -327,10 +368,7 @@ https://streams.example.test/url-omega.m3u8 }); await openSources(app.mainWindow); - await deleteSource( - app.mainWindow, - deletableLocalSourceDisplayName - ); + await deleteSource(app.mainWindow, deletableLocalSourceDisplayName); await expect( sourceRowByTitle( app.mainWindow, @@ -428,6 +466,10 @@ https://streams.example.test/refreshed-url.m3u8 ); await refreshSource(app.mainWindow, 'refresh-url-source.m3u'); await expectPlaylistUpdatedToast(app.mainWindow); + await waitForSourceRowIdle( + app.mainWindow, + 'refresh-url-source.m3u' + ); await sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u') .first() .click(); diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts new file mode 100644 index 000000000..59876cc51 --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -0,0 +1,229 @@ +import { + addXtreamPortal, + channelItemByTitle, + clickCategoryByNameExact, + closeElectronApp, + expect, + launchElectronApp, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { + fetchXtreamEpgFixture, + XtreamEpgFixture, + XtreamNormalizedEpgListing, +} from './portal-mock-fixtures'; + +const epgPortalName = 'Xtream EPG Fixture'; +const epgCredentials = { + username: 'epg', + password: 'epg', +}; + +for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { + test(`renders Xtream EPG previews and selected-channel schedule in ${timeZone}`, async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const currentProgram = fixture.shortEpg[0]; + if (!currentProgram) { + throw new Error('Expected the Xtream EPG fixture to include a current program.'); + } + const app = await launchElectronApp(dataDir, { + env: { TZ: timeZone }, + }); + + try { + await addXtreamPortal(app.mainWindow, { + name: `${epgPortalName} ${timeZone}`, + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + + const channelRow = channelItemByTitle( + app.mainWindow, + fixture.stream.name ?? '' + ).first(); + await expect(channelRow).toBeVisible({ timeout: 20000 }); + + await expect + .poll(async () => + ((await channelRow.locator('.epg-title').textContent()) ?? '').trim() + ) + .toBe(currentProgram.title); + await expect + .poll(async () => { + return (await channelRow.locator('.epg-time').allInnerTexts()).map( + (value) => value.trim() + ); + }) + .toEqual([ + formatTimeInZone(currentProgram.startTimestamp, timeZone), + formatTimeInZone(currentProgram.stopTimestamp, timeZone), + ]); + await expect + .poll(async () => getProgressWidthPercent(channelRow)) + .toBeGreaterThan(0); + + await channelRow.click(); + await expect(app.mainWindow.locator('app-epg-list')).toBeVisible({ + timeout: 20000, + }); + + const currentDateKey = formatDateKeyInZone(Date.now(), timeZone); + const currentDayTitles = visibleTitlesForCurrentDate( + fixture.fullEpg, + currentDateKey, + timeZone + ); + + await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual( + currentDayTitles + ); + + const currentProgramRow = app.mainWindow + .locator('app-epg-list .program-item.current-program') + .first(); + await expect(currentProgramRow).toBeVisible(); + await expect(currentProgramRow.locator('.program-title')).toHaveText( + currentProgram.title + ); + await expect(currentProgramRow.locator('.time')).toHaveText( + `${formatTimeInZone( + currentProgram.startTimestamp, + timeZone + )} - ${formatTimeInZone(currentProgram.stopTimestamp, timeZone)}` + ); + + const firstFutureDateKey = getFirstFutureDateKey( + fixture.fullEpg, + currentDateKey, + timeZone + ); + expect(firstFutureDateKey).not.toBeNull(); + + const futureDayTitles = titlesForDate( + fixture.fullEpg, + firstFutureDateKey!, + timeZone + ); + const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!); + + for (let index = 0; index < nextDayClicks; index += 1) { + await app.mainWindow.locator('app-epg-list .next-day').click(); + } + + await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual( + futureDayTitles + ); + } finally { + await closeElectronApp(app); + } + }); +} + +function formatTimeInZone( + timestampSeconds: number, + timeZone: string +): string { + return new Intl.DateTimeFormat('en-US', { + timeZone, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(new Date(timestampSeconds * 1000)); +} + +function formatDateKeyInZone( + timestampMilliseconds: number, + timeZone: string +): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(timestampMilliseconds)); + + const year = parts.find((part) => part.type === 'year')?.value; + const month = parts.find((part) => part.type === 'month')?.value; + const day = parts.find((part) => part.type === 'day')?.value; + + if (!year || !month || !day) { + throw new Error(`Failed to format a date key for timezone ${timeZone}.`); + } + + return `${year}-${month}-${day}`; +} + +function titlesForDate( + listings: XtreamNormalizedEpgListing[], + dateKey: string, + timeZone: string +): string[] { + return listings + .filter( + (listing) => + formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) === + dateKey + ) + .map((listing) => listing.title); +} + +function visibleTitlesForCurrentDate( + listings: XtreamNormalizedEpgListing[], + currentDateKey: string, + timeZone: string +): string[] { + const nowSeconds = Math.floor(Date.now() / 1000); + + return listings + .filter( + (listing) => + formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) === + currentDateKey && listing.stopTimestamp >= nowSeconds + ) + .map((listing) => listing.title); +} + +function getFirstFutureDateKey( + listings: XtreamNormalizedEpgListing[], + currentDateKey: string, + timeZone: string +): string | null { + const uniqueDateKeys = Array.from( + new Set( + listings.map((listing) => + formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) + ) + ) + ).sort(); + + return uniqueDateKeys.find((dateKey) => dateKey > currentDateKey) ?? null; +} + +function dayDifference(fromDateKey: string, toDateKey: string): number { + const from = Date.parse(`${fromDateKey}T00:00:00.000Z`); + const to = Date.parse(`${toDateKey}T00:00:00.000Z`); + return Math.round((to - from) / (24 * 60 * 60 * 1000)); +} + +async function visibleProgramTitles(page: Parameters[0]) { + return page + .locator('app-epg-list .program-title') + .allInnerTexts() + .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); +} + +async function getProgressWidthPercent(row: ReturnType) { + const style = await row.locator('.epg-progress-fill').getAttribute('style'); + const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0'; + return Number.parseFloat(width); +} diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index fd621e65c..00efcd744 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -53,10 +53,14 @@ test.describe('Electron Xtream Responsiveness', () => { username: stressXtreamUsername, }); - const overlay = app.mainWindow.locator('.workspace-loading-overlay'); + const overlay = app.mainWindow.locator( + '.workspace-loading-overlay' + ); await expect(overlay).toBeVisible({ timeout: 15000 }); await expect( - overlay.getByRole('button', { name: /cancel import/i }) + overlay.getByRole('button', { + name: /stop sync|cancel import/i, + }) ).toBeVisible({ timeout: 20000 }); await waitForDbOperationEvent(app.mainWindow, { @@ -71,15 +75,14 @@ test.describe('Electron Xtream Responsiveness', () => { await expect .poll( async () => - ( - await app.mainWindow.evaluate(() => + (await app.mainWindow.evaluate( + () => (window.__dbOperationEvents ?? []).filter( (event) => event.operation === 'save-content' && event.status === 'progress' ).length - ) - ) > 2, + )) > 2, { timeout: 30000 } ) .toBeTruthy(); @@ -125,9 +128,11 @@ test.describe('Electron Xtream Responsiveness', () => { username: stressXtreamUsername, }); - const overlay = app.mainWindow.locator('.workspace-loading-overlay'); + const overlay = app.mainWindow.locator( + '.workspace-loading-overlay' + ); const cancelButton = overlay.getByRole('button', { - name: /cancel import/i, + name: /stop sync|cancel import/i, }); await expect(overlay).toBeVisible({ timeout: 15000 }); @@ -145,9 +150,10 @@ test.describe('Electron Xtream Responsiveness', () => { await cancelButton.click(); await expect(overlay).toHaveCount(0, { timeout: 20000 }); - await expect( - xtreamBlockedStateTitle(app.mainWindow) - ).toHaveText('Import cancelled', { timeout: 20000 }); + await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText( + 'Import cancelled', + { timeout: 20000 } + ); await app.mainWindow.waitForTimeout(1200); await expect(overlay).toHaveCount(0); @@ -267,7 +273,9 @@ test.describe('Electron Xtream Responsiveness', () => { await playlistRow.locator('.delete-btn').click(); - const confirmDialog = app.mainWindow.locator('mat-dialog-container'); + const confirmDialog = app.mainWindow.locator( + 'mat-dialog-container' + ); await expect(confirmDialog).toBeVisible(); await confirmDialog .getByRole('button', { name: 'Yes', exact: true }) @@ -279,9 +287,9 @@ test.describe('Electron Xtream Responsiveness', () => { timeoutMs: 20000, }); - await expect(playlistRow.locator('.busy-state__message')).toBeVisible( - { timeout: 20000 } - ); + await expect( + playlistRow.locator('.busy-state__message') + ).toBeVisible({ timeout: 20000 }); await expect(playlistRow.locator('.cancel-btn')).toBeVisible(); await waitForDbOperationEvent(app.mainWindow, { diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 42fef0ee8..06a0bbf51 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -44,17 +44,15 @@ "serve": { "executor": "nx:run-commands", "options": { - "commands": [ - "nx run web:serve", - "nx run electron-backend:serve-electron" - ] + "command": "nx run electron-backend:serve-electron" } }, "serve-electron": { "executor": "nx-electron:execute", "options": { "buildTarget": "electron-backend:build", - "args": ["--remote-debugging-port=9222"] + "waitUntilTargets": ["web:serve"], + "remoteDebuggingPort": 9222 } }, "package": { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 1d20e000b..1a7329072 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -5,6 +5,12 @@ import type { PlaylistRefreshPayload, XtreamCategory, } from 'shared-interfaces'; +import { + DEBUG_TRACE_EVENT_CHANNEL, + isRendererApiTraceEnabled, + roundTraceDuration, + summarizeForTrace, +} from '../services/debug-trace'; const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT'; const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE'; @@ -40,7 +46,118 @@ const dbSaveContentProgressListeners = new Set< (event: Electron.IpcRendererEvent, data: DbOperationEvent) => void >(); -contextBridge.exposeInMainWorld('electron', { +const shouldTraceRendererApi = isRendererApiTraceEnabled(); + +function emitRendererTrace(payload: { + method: string; + phase: 'start' | 'success' | 'error'; + args?: unknown; + durationMs?: number; + error?: unknown; + result?: unknown; +}): void { + if (!shouldTraceRendererApi) { + return; + } + + ipcRenderer.send(DEBUG_TRACE_EVENT_CHANNEL, payload); +} + +function wrapElectronApi>(api: T): T { + if (!shouldTraceRendererApi) { + return api; + } + + return Object.fromEntries( + Object.entries(api).map(([name, value]) => { + if ( + typeof value !== 'function' || + name.startsWith('on') || + name.startsWith('remove') + ) { + return [name, value]; + } + + const original = value as (...args: unknown[]) => unknown; + + return [ + name, + (...args: unknown[]) => { + const startedAt = + globalThis.performance?.now?.() ?? Date.now(); + emitRendererTrace({ + args: summarizeForTrace(args), + method: name, + phase: 'start', + }); + + try { + const result = original(...args); + + if ( + result && + typeof (result as PromiseLike).then === + 'function' + ) { + return (result as Promise) + .then((resolvedValue) => { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace( + resolvedValue + ), + }); + return resolvedValue; + }) + .catch((error: unknown) => { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + throw error; + }); + } + + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace(result), + }); + + return result; + } catch (error) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + throw error; + } + }, + ]; + }) + ) as T; +} + +const electronApi = { // Remote control channel change listener onChannelChange: ( callback: (data: { direction: 'up' | 'down' }) => void @@ -254,6 +371,8 @@ contextBridge.exposeInMainWorld('electron', { }) => ipcRenderer.invoke('XTREAM_REQUEST', payload), xtreamCancelSession: (sessionId: string) => ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId), + xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') => + ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }), refreshPlaylist: (payload: PlaylistRefreshPayload) => ipcRenderer.invoke('PLAYLIST:REFRESH', payload), cancelPlaylistRefresh: (operationId: string) => @@ -493,4 +612,6 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.on('DOWNLOADS_UPDATE_EVENT', handler); return () => ipcRenderer.off('DOWNLOADS_UPDATE_EVENT', handler); }, -}); +}; + +contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi)); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index dfd9e4b11..488ea0832 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -1,8 +1,95 @@ import { app, BrowserWindow, Menu, screen, shell } from 'electron'; import { join } from 'path'; import { rendererAppName, rendererAppPort } from './constants'; +import { + isRendererConsoleTraceEnabled, + isWindowTraceEnabled, + trace, +} from './services/debug-trace'; import { store, WINDOW_BOUNDS } from './services/store.service'; +function attachWindowTrace(mainWindow: Electron.BrowserWindow): void { + if (!isWindowTraceEnabled()) { + return; + } + + const webContents = mainWindow.webContents; + + trace('window', 'created', { + id: mainWindow.id, + }); + + mainWindow.on('unresponsive', () => { + trace('window', 'unresponsive', { + id: mainWindow.id, + url: webContents.getURL(), + }); + }); + mainWindow.on('responsive', () => { + trace('window', 'responsive', { + id: mainWindow.id, + url: webContents.getURL(), + }); + }); + + webContents.on('did-start-loading', () => { + trace('window', 'did-start-loading', { + id: mainWindow.id, + url: webContents.getURL(), + }); + }); + webContents.on('dom-ready', () => { + trace('window', 'dom-ready', { + id: mainWindow.id, + url: webContents.getURL(), + }); + }); + webContents.on('did-finish-load', () => { + trace('window', 'did-finish-load', { + id: mainWindow.id, + url: webContents.getURL(), + }); + }); + webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL) => { + trace('window', 'did-fail-load', { + errorCode, + errorDescription, + id: mainWindow.id, + validatedURL, + }); + } + ); + webContents.on('did-navigate', (_event, url) => { + trace('window', 'did-navigate', { + id: mainWindow.id, + url, + }); + }); + webContents.on('render-process-gone', (_event, details) => { + trace('window', 'render-process-gone', { + details, + id: mainWindow.id, + url: webContents.getURL(), + }); + }); + + if (isRendererConsoleTraceEnabled()) { + webContents.on( + 'console-message', + (_event, level, message, line, sourceId) => { + trace('renderer-console', 'message', { + level, + line, + message, + sourceId, + }); + } + ); + } +} + export default class App { // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. @@ -10,6 +97,10 @@ export default class App { static application: Electron.App; static BrowserWindow; + private static shouldOpenDevTools() { + return process.env.ELECTRON_OPEN_DEVTOOLS === '1'; + } + public static isDevelopmentMode() { // First check ELECTRON_IS_DEV environment variable (used by E2E tests) // This allows E2E tests to run in production mode without packaging @@ -90,6 +181,7 @@ export default class App { : {}), }); App.mainWindow.setMenu(null); + attachWindowTrace(App.mainWindow); if (!savedWindowBounds) { App.mainWindow.center(); } @@ -161,7 +253,9 @@ export default class App { // load the index.html of the app. if (App.isDevelopmentMode()) { App.mainWindow.loadURL(`http://localhost:${rendererAppPort}`); - App.mainWindow.webContents.openDevTools(); + if (App.shouldOpenDevTools()) { + App.mainWindow.webContents.openDevTools(); + } } else { App.mainWindow.loadFile( join(__dirname, '..', rendererAppName, 'index.html') 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 efc8e59dc..e310b2242 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -44,6 +44,23 @@ function getRecentlyAddedContentTypes( return ['movie', 'series']; } +function selectContentFields() { + return { + id: schema.content.id, + category_id: schema.content.categoryId, + title: schema.content.title, + rating: schema.content.rating, + added: schema.content.added, + poster_url: schema.content.posterUrl, + epg_channel_id: schema.content.epgChannelId, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + direct_source: schema.content.directSource, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + }; +} + export async function hasContent( db: AppDatabase, playlistId: string, @@ -72,16 +89,7 @@ export async function getContent( type: 'live' | 'movie' | 'series' ) { const baseQuery = db - .select({ - id: schema.content.id, - category_id: schema.content.categoryId, - title: schema.content.title, - rating: schema.content.rating, - added: schema.content.added, - poster_url: schema.content.posterUrl, - xtream_id: schema.content.xtreamId, - type: schema.content.type, - }) + .select(selectContentFields()) .from(schema.content) .innerJoin( schema.categories, @@ -112,15 +120,8 @@ export async function getGlobalRecentlyAdded( return db .select({ - id: schema.content.id, - category_id: schema.content.categoryId, - title: schema.content.title, - rating: schema.content.rating, - added: schema.content.added, + ...selectContentFields(), added_at: schema.content.added, - poster_url: schema.content.posterUrl, - xtream_id: schema.content.xtreamId, - type: schema.content.type, playlist_id: schema.playlists.id, playlist_name: schema.playlists.name, }) @@ -150,6 +151,10 @@ type XtreamContentValue = { rating: string; added: string; posterUrl: string; + epgChannelId?: string | null; + tvArchive?: number | null; + tvArchiveDuration?: number | null; + directSource?: string | null; xtreamId: number; type: 'live' | 'movie' | 'series'; }; @@ -161,12 +166,16 @@ type XtreamContentSource = Record & { last_modified?: string; added?: string; stream_icon?: string; - poster?: string; - cover?: string; - name?: string; - title?: string; - series_id?: string | number; - stream_id?: string | number; + poster?: string; + cover?: string; + name?: string; + title?: string; + epg_channel_id?: string; + tv_archive?: string | number; + tv_archive_duration?: string | number; + direct_source?: string; + series_id?: string | number; + stream_id?: string | number; }; function toXtreamContentValue( @@ -205,6 +214,25 @@ function toXtreamContentValue( posterUrl: String( source.stream_icon || source.poster || source.cover || '' ), + epgChannelId: + type === 'live' + ? String(source.epg_channel_id ?? '').trim() || null + : null, + tvArchive: + type === 'live' + ? Number.parseInt(String(source.tv_archive ?? '0'), 10) || 0 + : null, + tvArchiveDuration: + type === 'live' + ? Number.parseInt( + String(source.tv_archive_duration ?? '0'), + 10 + ) || 0 + : null, + directSource: + type === 'live' + ? String(source.direct_source ?? '').trim() || null + : null, xtreamId: type === 'series' ? parseInt(String(source.series_id || '0')) @@ -343,16 +371,7 @@ export async function getContentByXtreamId( playlistId: string ) { const result = await db - .select({ - id: schema.content.id, - category_id: schema.content.categoryId, - title: schema.content.title, - rating: schema.content.rating, - added: schema.content.added, - poster_url: schema.content.posterUrl, - xtream_id: schema.content.xtreamId, - type: schema.content.type, - }) + .select(selectContentFields()) .from(schema.content) .innerJoin( schema.categories, @@ -397,16 +416,7 @@ export async function searchContent( } const candidates = await db - .select({ - id: schema.content.id, - category_id: schema.content.categoryId, - title: schema.content.title, - rating: schema.content.rating, - added: schema.content.added, - poster_url: schema.content.posterUrl, - xtream_id: schema.content.xtreamId, - type: schema.content.type, - }) + .select(selectContentFields()) .from(schema.content) .innerJoin( schema.categories, @@ -449,14 +459,7 @@ export async function globalSearch( const candidates = await db .select({ - id: schema.content.id, - category_id: schema.content.categoryId, - title: schema.content.title, - rating: schema.content.rating, - added: schema.content.added, - poster_url: schema.content.posterUrl, - xtream_id: schema.content.xtreamId, - type: schema.content.type, + ...selectContentFields(), playlist_id: schema.categories.playlistId, playlist_name: schema.playlists.name, }) diff --git a/apps/electron-backend/src/app/events/electron.events.ts b/apps/electron-backend/src/app/events/electron.events.ts index 47795d0f7..b01b177ca 100644 --- a/apps/electron-backend/src/app/events/electron.events.ts +++ b/apps/electron-backend/src/app/events/electron.events.ts @@ -6,6 +6,11 @@ import { app, ipcMain } from 'electron'; import * as os from 'os'; import { environment } from '../../environments/environment'; +import { + DEBUG_TRACE_EVENT_CHANNEL, + isRendererApiTraceEnabled, + trace, +} from '../services/debug-trace'; export default class ElectronEvents { static bootstrapElectronEvents(): Electron.IpcMain { @@ -25,6 +30,18 @@ ipcMain.on('quit', (event, code) => { app.exit(code); }); +ipcMain.on(DEBUG_TRACE_EVENT_CHANNEL, (event, payload) => { + if (!isRendererApiTraceEnabled()) { + return; + } + + trace('renderer-api', 'event', { + payload, + senderId: event.sender.id, + url: event.sender.getURL(), + }); +}); + // Get local IP addresses for remote control URL display ipcMain.handle('get-local-ip-addresses', () => { const interfaces = os.networkInterfaces(); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 73f21485f..c117bc191 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -245,3 +245,46 @@ type ActiveXtreamRequest = { }; const activeXtreamRequests = new Map(); + +ipcMain.handle( + 'XTREAM_PROBE_URL', + async ( + _event, + payload: { + url: string; + method?: 'GET' | 'HEAD'; + } + ) => { + const config: AxiosRequestConfig = { + method: payload.method ?? 'HEAD', + url: payload.url, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + timeout: 10000, + maxRedirects: 5, + validateStatus: () => true, + }; + + try { + const response = await axios(config); + return { + status: response.status, + url: payload.url, + }; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + return { + status: error.response.status, + url: payload.url, + }; + } + + return { + status: 0, + url: payload.url, + }; + } + } +); diff --git a/apps/electron-backend/src/app/services/database-worker-client.ts b/apps/electron-backend/src/app/services/database-worker-client.ts index d09d76a04..a7b8294d5 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.ts @@ -8,12 +8,20 @@ import type { DbWorkerMessage, DbWorkerOperation, } from '../workers/database-worker.types'; +import { + isDbTraceEnabled, + roundTraceDuration, + summarizeForTrace, + trace, +} from './debug-trace'; import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths'; type PendingRequest = { resolve: (value: unknown) => void; reject: (reason?: unknown) => void; onEvent?: (event: DbOperationEvent) => void; + operation: DbWorkerOperation; + startedAt: number; }; type RequestOptions = { @@ -47,12 +55,23 @@ export class DatabaseWorkerClient { await this.ensureWorker(); const requestId = randomUUID(); + const startedAt = Date.now(); + + if (isDbTraceEnabled()) { + trace('db-request', 'dispatch', { + operation, + payload: summarizeForTrace(payload), + requestId, + }); + } return new Promise((resolve, reject) => { this.pendingRequests.set(requestId, { resolve: resolve as (value: unknown) => void, reject, onEvent: options?.onEvent, + operation, + startedAt, }); this.worker?.postMessage({ @@ -126,6 +145,15 @@ export class DatabaseWorkerClient { try { const workerURL = pathToFileURL(bootstrap.workerPath); + + if (isDbTraceEnabled()) { + trace('db-worker', 'create', { + nativeModuleSearchPaths: + bootstrap.nativeModuleSearchPaths?.length ?? 0, + workerPath: bootstrap.workerPath, + }); + } + this.worker = new Worker(workerURL, { workerData: { nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths, @@ -150,6 +178,9 @@ export class DatabaseWorkerClient { private handleMessage(message: DbWorkerMessage): void { if (message.type === 'ready') { + if (isDbTraceEnabled()) { + trace('db-worker', 'ready'); + } this.readyResolve?.(); this.readyResolve = null; this.readyReject = null; @@ -157,6 +188,12 @@ export class DatabaseWorkerClient { } if (message.type === 'event') { + if (isDbTraceEnabled()) { + trace('db-event', 'worker-event', { + event: message.event, + requestId: message.requestId, + }); + } this.pendingRequests.get(message.requestId)?.onEvent?.(message.event); return; } @@ -169,10 +206,31 @@ export class DatabaseWorkerClient { this.pendingRequests.delete(message.requestId); if (message.success) { + if (isDbTraceEnabled()) { + trace('db-request', 'resolved', { + durationMs: roundTraceDuration( + Date.now() - pendingRequest.startedAt + ), + operation: pendingRequest.operation, + requestId: message.requestId, + result: summarizeForTrace(message.result), + }); + } pendingRequest.resolve(message.result); return; } + if (isDbTraceEnabled()) { + trace('db-request', 'failed', { + durationMs: roundTraceDuration( + Date.now() - pendingRequest.startedAt + ), + error: message.error, + operation: pendingRequest.operation, + requestId: message.requestId, + }); + } + pendingRequest.reject( createWorkerError( message.error ?? { message: 'Database worker request failed' } @@ -181,6 +239,10 @@ export class DatabaseWorkerClient { } private handleWorkerFailure(error: Error): void { + if (isDbTraceEnabled()) { + trace('db-worker', 'error', error); + } + if (this.readyReject) { this.readyReject(error); } @@ -194,6 +256,10 @@ export class DatabaseWorkerClient { return; } + if (isDbTraceEnabled()) { + trace('db-worker', 'exit', { code }); + } + const error = code === 0 ? new Error('Database worker exited unexpectedly') diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts new file mode 100644 index 000000000..8a24806ff --- /dev/null +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -0,0 +1,178 @@ +const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const TRACE_PREFIX = '[IPTVnator Trace]'; +const MAX_TRACE_ARRAY_ITEMS = 5; +const MAX_TRACE_OBJECT_KEYS = 8; +const MAX_TRACE_STRING_LENGTH = 180; +const MAX_TRACE_DEPTH = 2; + +export const DEBUG_TRACE_EVENT_CHANNEL = 'IPTVNATOR_DEBUG_TRACE_EVENT'; + +function readFlag(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase(); + return value ? TRACE_ENV_TRUE_VALUES.has(value) : false; +} + +function truncateString(value: string): string { + if (value.length <= MAX_TRACE_STRING_LENGTH) { + return value; + } + + return `${value.slice(0, MAX_TRACE_STRING_LENGTH - 3)}...`; +} + +function summarizeObject( + value: Record, + depth: number +): Record { + const entries = Object.entries(value); + const summary: Record = {}; + + if ( + value.constructor?.name && + value.constructor.name !== 'Object' + ) { + summary.__type = value.constructor.name; + } + + entries.slice(0, MAX_TRACE_OBJECT_KEYS).forEach(([key, entryValue]) => { + summary[key] = summarizeForTrace(entryValue, depth + 1); + }); + + if (entries.length > MAX_TRACE_OBJECT_KEYS) { + summary.__moreKeys = entries.length - MAX_TRACE_OBJECT_KEYS; + } + + return summary; +} + +export function isStartupTraceEnabled(): boolean { + return readFlag('IPTVNATOR_TRACE_STARTUP'); +} + +export function isRendererApiTraceEnabled(): boolean { + return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_IPC'); +} + +export function isDbTraceEnabled(): boolean { + return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB'); +} + +export function isSqlTraceEnabled(): boolean { + return ( + isStartupTraceEnabled() || + readFlag('IPTVNATOR_TRACE_DB') || + readFlag('IPTVNATOR_TRACE_SQL') + ); +} + +export function isWindowTraceEnabled(): boolean { + return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_WINDOW'); +} + +export function isRendererConsoleTraceEnabled(): boolean { + return readFlag('IPTVNATOR_TRACE_RENDERER_CONSOLE'); +} + +export function roundTraceDuration(durationMs: number): number { + return Math.round(durationMs * 10) / 10; +} + +export function compactSqlForTrace(sql: string): string { + return truncateString(sql.replace(/\s+/g, ' ').trim()); +} + +export function summarizeForTrace( + value: unknown, + depth = 0 +): unknown { + if ( + value == null || + typeof value === 'boolean' || + typeof value === 'number' + ) { + return value; + } + + if (typeof value === 'string') { + return truncateString(value); + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (typeof value === 'function') { + return '[Function]'; + } + + if (value instanceof Error) { + return { + name: value.name, + message: truncateString(value.message), + }; + } + + if (depth >= MAX_TRACE_DEPTH) { + if (Array.isArray(value)) { + return { + type: 'array', + length: value.length, + }; + } + + return typeof value === 'object' + ? { + type: + (value as { constructor?: { name?: string } }).constructor + ?.name ?? 'object', + } + : String(value); + } + + if (Array.isArray(value)) { + return { + type: 'array', + length: value.length, + items: value + .slice(0, MAX_TRACE_ARRAY_ITEMS) + .map((entry) => summarizeForTrace(entry, depth + 1)), + }; + } + + if (typeof value === 'object') { + return summarizeObject(value as Record, depth); + } + + return String(value); +} + +export function safeStringifyForTrace(payload: unknown): string { + try { + return JSON.stringify(payload); + } catch (error) { + return JSON.stringify({ + fallback: summarizeForTrace(payload), + stringifyError: + error instanceof Error + ? truncateString(error.message) + : String(error), + }); + } +} + +export function trace( + scope: string, + message: string, + payload?: unknown +): void { + if (payload === undefined) { + console.log(`${TRACE_PREFIX}[${scope}] ${message}`); + return; + } + + console.log( + `${TRACE_PREFIX}[${scope}] ${message} ${safeStringifyForTrace( + summarizeForTrace(payload) + )}` + ); +} diff --git a/apps/electron-backend/src/app/workers/database.worker-connection.ts b/apps/electron-backend/src/app/workers/database.worker-connection.ts index ccf5ae97d..8e6d79d78 100644 --- a/apps/electron-backend/src/app/workers/database.worker-connection.ts +++ b/apps/electron-backend/src/app/workers/database.worker-connection.ts @@ -9,6 +9,11 @@ import { loadNativeModuleFromSearchPaths, registerNativeModuleSearchPaths, } from './worker-runtime-paths'; +import { + compactSqlForTrace, + isSqlTraceEnabled, + trace, +} from '../services/debug-trace'; let Database: typeof BetterSqlite3; let drizzleFactory: @@ -63,11 +68,25 @@ export async function getWorkerDatabase(): Promise { } const filePath = getIptvnatorDatabasePath(); - sqlite = new Database(filePath); + sqlite = new Database(filePath, { + verbose: isSqlTraceEnabled() + ? (sql: string) => { + trace('sql-worker', 'query', { + sql: compactSqlForTrace(sql), + }); + } + : undefined, + }); sqlite.pragma('foreign_keys = ON'); sqlite.pragma('journal_mode = WAL'); sqlite.pragma('busy_timeout = 5000'); + if (isSqlTraceEnabled()) { + trace('sql-worker', 'open', { + filePath, + }); + } + db = getDrizzleFactory()(sqlite, { schema }); return db; } @@ -78,6 +97,11 @@ export function closeWorkerDatabase(): void { } sqlite.close(); + + if (isSqlTraceEnabled()) { + trace('sql-worker', 'close'); + } + sqlite = null; db = null; } diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 6e5d4cf42..bba772f58 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -17,6 +17,7 @@ import SettingsEvents from './app/events/settings.events'; import SharedEvents from './app/events/shared.events'; import SquirrelEvents from './app/events/squirrel.events'; import StalkerEvents from './app/events/stalker.events'; +import { isStartupTraceEnabled, trace } from './app/services/debug-trace'; import { databaseWorkerClient } from './app/services/database-worker-client'; import XtreamEvents from './app/events/xtream.events'; @@ -37,13 +38,24 @@ export default class Main { } static bootstrapApp() { + if (isStartupTraceEnabled()) { + trace('startup', 'bootstrap-app'); + } App.main(app, BrowserWindow); } static async bootstrapAppEvents() { + if (isStartupTraceEnabled()) { + trace('startup', 'bootstrap-events:start'); + } + // Initialize database before other events await initDatabase(); + if (isStartupTraceEnabled()) { + trace('startup', 'init-database:done'); + } + ElectronEvents.bootstrapElectronEvents(); PlaylistEvents.bootstrapPlaylistEvents(); SharedEvents.bootstrapSharedEvents(); @@ -61,10 +73,18 @@ export default class Main { } await resetStaleDownloads(); + if (isStartupTraceEnabled()) { + trace('startup', 'reset-stale-downloads:done'); + } + // initialize auto updater service if (!App.isDevelopmentMode()) { // UpdateEvents.initAutoUpdateService(); } + + if (isStartupTraceEnabled()) { + trace('startup', 'bootstrap-events:done'); + } } } @@ -78,6 +98,9 @@ Main.bootstrapApp(); // Bootstrap app events after Electron app is ready app.whenReady().then(async () => { + if (isStartupTraceEnabled()) { + trace('startup', 'app.whenReady'); + } await Main.bootstrapAppEvents(); }); diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 1e864e4f6..3b18bc6e1 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -17,18 +17,9 @@ async function saveSettings(page: Page) { test.describe('Settings', () => { test.beforeEach(async ({ page }) => { + // Playwright creates a fresh browser context per test, so extra + // IndexedDB cleanup here only risks racing with app-managed DB handles. await page.goto('/'); - // Clear IndexedDB before each test - await page.evaluate(async () => { - const dbNames = (await window.indexedDB.databases()).map( - (db) => db.name - ); - dbNames.forEach((name) => - name !== undefined - ? window.indexedDB.deleteDatabase(name) - : null - ); - }); }); test('Check settings page', async ({ page }) => { diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 39e2b90d4..af4a5068a 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -90,20 +90,6 @@ async function addStalkerPortal( await page.waitForURL(/stalker.*vod/); } -/** - * Clear IndexedDB state between tests so each test starts fresh. - */ -async function clearStorage(page: Page): Promise { - await page.evaluate(async () => { - const dbs = await window.indexedDB.databases(); - await Promise.all( - dbs - .filter((db) => db.name != null) - .map((db) => window.indexedDB.deleteDatabase(db.name!)) - ); - }); -} - // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- @@ -112,9 +98,9 @@ test.beforeEach(async ({ page, request }) => { // Reset mock server state (clears in-memory favorites and cache) await request.post(`${MOCK_SERVER}/reset`); + // Playwright creates a fresh browser context per test, so extra + // IndexedDB cleanup here only risks racing with app-managed DB handles. await page.goto('/'); - await clearStorage(page); - await page.reload(); // Redirect backend proxy calls to the mock server await interceptStalkerRequests(page); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 7152fa0e9..e36ba9d86 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -1,4 +1,4 @@ -import { test, expect, Page } from '@playwright/test'; +import { APIRequestContext, test, expect, Page } from '@playwright/test'; /** * Xtream Codes E2E Tests @@ -21,6 +21,8 @@ const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`; /** Default scenario credentials */ const DEFAULT_USERNAME = 'user1'; const DEFAULT_PASSWORD = 'pass1'; +const EPG_USERNAME = 'epg'; +const EPG_PASSWORD = 'epg'; // --------------------------------------------------------------------------- // Helpers @@ -68,20 +70,6 @@ async function addXtreamPortal( await page.waitForURL(/xtreams.*vod/); } -/** - * Clear IndexedDB state between tests so each test starts fresh. - */ -async function clearStorage(page: Page): Promise { - await page.evaluate(async () => { - const dbs = await window.indexedDB.databases(); - await Promise.all( - dbs - .filter((db) => db.name != null) - .map((db) => window.indexedDB.deleteDatabase(db.name!)) - ); - }); -} - // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- @@ -89,9 +77,9 @@ async function clearStorage(page: Page): Promise { test.beforeEach(async ({ page, request }) => { await request.post(`${MOCK_SERVER}/reset`); + // Playwright creates a fresh browser context per test, so extra + // IndexedDB cleanup here only risks racing with app-managed DB handles. await page.goto('/'); - await clearStorage(page); - await page.reload(); await interceptXtreamRequests(page); }); @@ -282,6 +270,92 @@ test('@xtream get_short_epg — returns base64-encoded listings', async ({ expect(listing.stop_timestamp).toBeDefined(); }); +test('@xtream epg fixture — short epg starts at the current program, respects limit, and exposes timestamp-vs-string mismatches', async ({ + request, +}) => { + const stream = await getEpgFixtureStream(request); + + const response = await request.get( + `${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_short_epg&stream_id=${stream.stream_id}&limit=2` + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + expect(body.epg_listings).toHaveLength(2); + + const [currentListing, nextListing] = body.epg_listings; + expect(decodeXtreamText(currentListing.title)).toBe('Global Headlines'); + expect(decodeXtreamText(nextListing.title)).toBe('Market Wrap'); + + const now = Math.floor(Date.now() / 1000); + expect(Number.parseInt(currentListing.start_timestamp, 10)).toBeLessThanOrEqual( + now + ); + expect(Number.parseInt(currentListing.stop_timestamp, 10)).toBeGreaterThanOrEqual( + now + ); + expect(currentListing.start).not.toBe( + formatXtreamDateTime( + Number.parseInt(currentListing.start_timestamp, 10) + ) + ); + expect(currentListing.end).not.toBe( + formatXtreamDateTime( + Number.parseInt(currentListing.stop_timestamp, 10) + ) + ); +}); + +test('@xtream epg fixture — full epg and legacy alias return the same ordered schedule with a midnight boundary program', async ({ + request, +}) => { + const stream = await getEpgFixtureStream(request); + + const fullResponse = await request.get( + `${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_data_table&stream_id=${stream.stream_id}` + ); + const aliasResponse = await request.get( + `${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_date_table&stream_id=${stream.stream_id}` + ); + + expect(fullResponse.ok()).toBeTruthy(); + expect(aliasResponse.ok()).toBeTruthy(); + + const fullBody = await fullResponse.json(); + const aliasBody = await aliasResponse.json(); + + expect(aliasBody).toEqual(fullBody); + + const titles = fullBody.epg_listings.map((listing: XtreamRawEpgListing) => + decodeXtreamText(listing.title) + ); + expect(titles).toEqual([ + 'Earlier Bulletin', + 'Global Headlines', + 'Market Wrap', + 'Overnight Update', + 'Late Edition', + 'After Midnight', + ]); + + const boundaryListing = fullBody.epg_listings.find( + (listing: XtreamRawEpgListing) => + decodeXtreamText(listing.title) === 'Late Edition' + ); + if (!boundaryListing) { + throw new Error('Expected the full EPG fixture to include Late Edition.'); + } + + const boundaryStart = new Date( + Number.parseInt(boundaryListing.start_timestamp, 10) * 1000 + ); + const boundaryStop = new Date( + Number.parseInt(boundaryListing.stop_timestamp, 10) * 1000 + ); + + expect(boundaryStart.getUTCDate()).not.toBe(boundaryStop.getUTCDate()); +}); + test('@xtream reset — data regenerates identically after reset', async ({ request, }) => { @@ -357,3 +431,57 @@ test('@xtream minimal scenario — reduced item count', async ({ request }) => { // 2 categories × 5 items = 10 expect(streams.length).toBe(10); }); + +type XtreamLiveStream = { + category_id: string; + name: string; + stream_id: number; +}; + +type XtreamRawEpgListing = { + end: string; + start: string; + start_timestamp: string; + stop_timestamp: string; + title: string; +}; + +async function getEpgFixtureStream( + request: APIRequestContext +): Promise { + const categories = (await ( + await request.get( + `${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_categories` + ) + ).json()) as Array<{ category_id: string; category_name: string }>; + + const epgCategory = categories.find( + (category) => category.category_name === 'EPG Focus' + ); + if (!epgCategory) { + throw new Error('Expected the EPG fixture category to exist.'); + } + + const streams = (await ( + await request.get( + `${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_streams&category_id=${epgCategory.category_id}` + ) + ).json()) as XtreamLiveStream[]; + + const stream = streams.find((item) => item.name === 'Timezone News'); + if (!stream) { + throw new Error('Expected the EPG fixture stream to exist.'); + } + return stream; +} + +function decodeXtreamText(value: string): string { + return Buffer.from(value, 'base64').toString('utf-8'); +} + +function formatXtreamDateTime(timestampSeconds: number): string { + return new Date(timestampSeconds * 1000) + .toISOString() + .replace('T', ' ') + .replace('.000Z', ''); +} diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index b5b23de6e..d2af6bea4 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -51,6 +51,8 @@ export class ElectronService extends DataService { XtreamCodeActions.GetVodCategories, XtreamCodeActions.GetSeriesCategories, XtreamCodeActions.GetShortEpg, + XtreamCodeActions.GetSimpleDataTable, + XtreamCodeActions.GetSimpleDateTable, ]); constructor() { @@ -263,8 +265,10 @@ export class ElectronService extends DataService { } private async fetchM3uPlaylistFromUrl(payload: Partial) { + const title = payload.title?.trim() || undefined; + window.electron - .fetchPlaylistByUrl(payload.url) + .fetchPlaylistByUrl(payload.url, title) .then((result) => { this.store.dispatch( PlaylistActions.handleAddingPlaylistByUrl({ diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index b3d20c234..3f7a26c4a 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -63,6 +63,8 @@ export class PwaService extends DataService { XtreamCodeActions.GetVodCategories, XtreamCodeActions.GetSeriesCategories, XtreamCodeActions.GetShortEpg, + XtreamCodeActions.GetSimpleDataTable, + XtreamCodeActions.GetSimpleDateTable, ]); /** Proxy URL to avoid CORS issues */ @@ -183,6 +185,8 @@ export class PwaService extends DataService { * @param payload playlist payload */ fetchFromUrl(payload: Partial): void { + const title = payload.title?.trim() || undefined; + this.getPlaylistFromUrl(payload.url) .pipe( catchError((error) => { @@ -197,10 +201,18 @@ export class PwaService extends DataService { }) ) .subscribe((response: Playlist) => { + const playlist = title + ? { + ...response, + filename: title, + title, + } + : response; + this.store.dispatch( PlaylistActions.handleAddingPlaylistByUrl({ isTemporary: !!payload?.isTemporary, - playlist: response, + playlist, }) ); }); diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index e9cfb7f13..75d29c588 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -817,7 +817,7 @@ -
+
@if (isDialog) { -
+ diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 8e52aef96..71c76f255 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -1,5 +1,8 @@ :host { display: block; + --settings-safe-bottom: env(safe-area-inset-bottom, 0px); + --settings-action-bar-offset: 24px; + --settings-action-bar-space: 112px; --settings-page-header-bg: color-mix( in srgb, var(--mat-sys-surface-container-low) 94%, @@ -139,14 +142,14 @@ .settings-container { height: auto; overflow: visible; - padding: 18px 20px 30px; + padding: 18px 20px calc(36px + var(--settings-safe-bottom)); } .settings-container.dialog-mode { height: auto; max-height: none; overflow: visible; - padding: 12px 8px 16px; + padding: 12px 8px calc(24px + var(--settings-safe-bottom)); } .settings-layout { @@ -156,7 +159,13 @@ align-items: stretch; max-width: 920px; margin: 0 auto; - padding-bottom: 72px; /* space for FAB */ + padding-bottom: calc( + var(--settings-action-bar-space) + var(--settings-safe-bottom) + ); +} + +.settings-container.dialog-mode .settings-layout { + padding-bottom: 0; } .settings-page-header { @@ -531,13 +540,16 @@ .action-bar { position: sticky; - bottom: 24px; + bottom: calc( + var(--settings-action-bar-offset) + var(--settings-safe-bottom) + ); margin-left: auto; z-index: 100; display: flex; justify-content: flex-end; align-items: center; gap: 12px; + flex-wrap: wrap; padding: 10px 14px; border: 1px solid var(--settings-action-bar-border); border-radius: 18px; @@ -548,7 +560,8 @@ .settings-container.dialog-mode .action-bar { position: static; - margin-top: 8px; + margin-top: 12px; + margin-bottom: 2px; box-shadow: none; border-radius: 16px; padding: 12px; @@ -565,7 +578,7 @@ @media (max-width: 860px) { .settings-container { height: auto; - padding: 12px; + padding: 12px 12px calc(18px + var(--settings-safe-bottom)); } .settings-page-header { diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index a504c8a54..d540533f1 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -234,46 +234,72 @@ describe('SettingsComponent', () => { ).not.toBeNull(); }); - it('should scroll the general navigation target to the general section', async () => { + it('should scroll the selected navigation target within the workspace viewport', async () => { fixture.destroy(); + jest.useFakeTimers(); - const scrollFixture = TestBed.createComponent(SettingsComponent); - const scrollComponent = scrollFixture.componentInstance; - const settingsContext = TestBed.inject(SettingsContextService); - const scrollIntoView = jest.fn(); - const originalGetElementById = document.getElementById.bind(document); + try { + const scrollFixture = TestBed.createComponent(SettingsComponent); + const scrollComponent = scrollFixture.componentInstance; + const settingsContext = TestBed.inject(SettingsContextService); + const originalGetElementById = document.getElementById.bind( + document + ); + const scrollTo = jest.fn(); + const scrollRoot = { + scrollTop: 96, + clientHeight: 885, + scrollHeight: 2469, + getBoundingClientRect: () => + ({ + top: 56, + }) as DOMRect, + scrollTo, + } as unknown as HTMLElement; - scrollComponent.checkAppVersion = jest.fn(); - scrollComponent.fetchLocalIpAddresses = jest - .fn() - .mockResolvedValue(undefined); - scrollFixture.detectChanges(); + scrollComponent.checkAppVersion = jest.fn(); + scrollComponent.fetchLocalIpAddresses = jest + .fn() + .mockResolvedValue(undefined); + jest.spyOn(scrollComponent as any, 'getScrollRoot').mockReturnValue( + scrollRoot + ); + scrollFixture.detectChanges(); - const getElementByIdSpy = jest - .spyOn(document, 'getElementById') - .mockImplementation((id: string) => { - if (id === 'general') { - return { - scrollIntoView, - } as unknown as HTMLElement; - } + const getElementByIdSpy = jest + .spyOn(document, 'getElementById') + .mockImplementation((id: string) => { + if (id === 'about') { + return { + getBoundingClientRect: () => + ({ + top: 2050, + height: 159, + }) as DOMRect, + } as HTMLElement; + } - return originalGetElementById(id); + return originalGetElementById(id); + }); + + await scrollFixture.whenStable(); + scrollFixture.detectChanges(); + settingsContext.navigateToSection('about'); + scrollFixture.detectChanges(); + + expect(getElementByIdSpy).toHaveBeenCalledWith('about'); + expect(scrollTo).toHaveBeenCalledWith({ + behavior: 'smooth', + top: 1488, }); + expect(settingsContext.pendingScrollTarget()).toBe('about'); - await scrollFixture.whenStable(); - scrollFixture.detectChanges(); - settingsContext.navigateToSection('general'); - scrollFixture.detectChanges(); - await scrollFixture.whenStable(); + jest.advanceTimersByTime(600); - expect(getElementByIdSpy).toHaveBeenCalledWith('general'); - expect(getElementByIdSpy).not.toHaveBeenCalledWith('settings-intro'); - expect(scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'start', - }); - expect(settingsContext.pendingScrollTarget()).toBeNull(); + expect(settingsContext.pendingScrollTarget()).toBeNull(); + } finally { + jest.useRealTimers(); + } }); describe('Get and set settings on component init', () => { @@ -384,6 +410,26 @@ describe('SettingsComponent', () => { undefined, { duration: 2000, + horizontalPosition: 'center', + panelClass: ['settings-snackbar'], + verticalPosition: 'bottom', + } + ); + }); + + it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => { + jest.spyOn(translate, 'instant').mockReturnValue('Settings saved'); + + component.applyChangedSettings(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Settings saved', + undefined, + { + duration: 2000, + horizontalPosition: 'center', + panelClass: ['settings-snackbar'], + verticalPosition: 'bottom', } ); }); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index d532ead66..a514be353 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -30,7 +30,10 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { + MatSnackBar, + MatSnackBarConfig, +} from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -97,6 +100,10 @@ interface StartupBehaviorOption { ], }) export class SettingsComponent implements OnInit, OnDestroy { + private static readonly SECTION_SCROLL_TOP_GUTTER = 112; + private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124; + private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600; + private dialogService = inject(DialogService); public dataService = inject(DataService); private epgService = inject(EpgService); @@ -234,6 +241,10 @@ export class SettingsComponent implements OnInit, OnDestroy { private settingsStore = inject(SettingsStore); private sectionObserver?: IntersectionObserver; + private pendingScrollClearTimer: ReturnType | null = + null; + private pendingScrollClearRoot: HTMLElement | null = null; + private pendingScrollEndListener: (() => void) | null = null; readonly sectionNavItems: SettingsSection[] = [ { @@ -282,10 +293,8 @@ export class SettingsComponent implements OnInit, OnDestroy { return; } - document - .getElementById(sectionId) - ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - this.settingsCtx.clearPendingScrollTarget(); + const scrollRoot = this.scrollToSection(sectionId); + this.schedulePendingScrollTargetClear(scrollRoot); }, { injector: this.injector } ); @@ -352,6 +361,7 @@ export class SettingsComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.cancelPendingScrollTargetClear(); this.sectionObserver?.disconnect(); this.settingsCtx.reset(); } @@ -517,13 +527,8 @@ export class SettingsComponent implements OnInit, OnDestroy { this.settingsService.changeTheme( this.settingsForm.value.theme ?? Theme.SystemTheme ); - this.snackBar.open( - this.translate.instant('SETTINGS.SETTINGS_SAVED'), - null, - { - duration: 2000, - horizontalPosition: 'start', - } + this.openSettingsSnackbar( + this.translate.instant('SETTINGS.SETTINGS_SAVED') ); } @@ -581,10 +586,8 @@ export class SettingsComponent implements OnInit, OnDestroy { onConfirm: async (): Promise => { if (window.electron?.clearEpgData) { await window.electron.clearEpgData(); - this.snackBar.open( - this.translate.instant('SETTINGS.EPG_DATA_CLEARED'), - null, - { duration: 2000, horizontalPosition: 'start' } + this.openSettingsSnackbar( + this.translate.instant('SETTINGS.EPG_DATA_CLEARED') ); } }, @@ -628,12 +631,8 @@ export class SettingsComponent implements OnInit, OnDestroy { ); if (!Array.isArray(parsedPlaylists)) { - this.snackBar.open( + this.openSettingsSnackbar( this.translate.instant('SETTINGS.IMPORT_ERROR'), - null, - { - duration: 2000, - } ); } else { this.store.dispatch( @@ -643,12 +642,8 @@ export class SettingsComponent implements OnInit, OnDestroy { ); } } catch (error) { - this.snackBar.open( + this.openSettingsSnackbar( this.translate.instant('SETTINGS.IMPORT_ERROR'), - null, - { - duration: 2000, - } ); console.error(error); } @@ -674,21 +669,13 @@ export class SettingsComponent implements OnInit, OnDestroy { try { await firstValueFrom(this.playlistsService.removeAll()); this.store.dispatch(PlaylistActions.removeAllPlaylists()); - this.snackBar.open( + this.openSettingsSnackbar( this.translate.instant('SETTINGS.PLAYLISTS_REMOVED'), - undefined, - { - duration: 2000, - } ); } catch (error) { console.error('Error removing playlists:', error); - this.snackBar.open( + this.openSettingsSnackbar( this.translate.instant('SETTINGS.IMPORT_ERROR'), - undefined, - { - duration: 2000, - } ); } finally { this.isRemovingAllPlaylists.set(false); @@ -722,6 +709,10 @@ export class SettingsComponent implements OnInit, OnDestroy { this.sectionObserver?.disconnect(); this.sectionObserver = new IntersectionObserver( () => { + if (this.settingsCtx.pendingScrollTarget()) { + return; + } + const activeSection = this.resolveActiveSection(sections); if (activeSection) { this.settingsCtx.setActiveSection(activeSection); @@ -777,4 +768,105 @@ export class SettingsComponent implements OnInit, OnDestroy { 'main.workspace-content' ) as HTMLElement | null; } + + private schedulePendingScrollTargetClear( + scrollRoot: HTMLElement | null + ): void { + const clearPendingScrollTarget = () => { + this.cancelPendingScrollTargetClear(); + this.settingsCtx.clearPendingScrollTarget(); + }; + + this.cancelPendingScrollTargetClear(); + this.pendingScrollClearTimer = window.setTimeout( + clearPendingScrollTarget, + SettingsComponent.PENDING_SCROLL_CLEAR_DELAY_MS + ); + this.pendingScrollClearRoot = scrollRoot; + this.pendingScrollEndListener = clearPendingScrollTarget; + scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, { + once: true, + }); + } + + private cancelPendingScrollTargetClear(): void { + if (this.pendingScrollClearTimer) { + clearTimeout(this.pendingScrollClearTimer); + this.pendingScrollClearTimer = null; + } + + if (this.pendingScrollClearRoot && this.pendingScrollEndListener) { + this.pendingScrollClearRoot.removeEventListener?.( + 'scrollend', + this.pendingScrollEndListener + ); + } + + this.pendingScrollClearRoot = null; + this.pendingScrollEndListener = null; + } + + private scrollToSection(sectionId: string): HTMLElement | null { + const sectionElement = document.getElementById(sectionId); + if (!sectionElement) { + return null; + } + + const scrollRoot = this.getScrollRoot(); + if (!scrollRoot) { + sectionElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + return null; + } + + const rootRect = scrollRoot.getBoundingClientRect(); + const sectionRect = sectionElement.getBoundingClientRect(); + const sectionTop = + scrollRoot.scrollTop + (sectionRect.top - rootRect.top); + const sectionBottom = sectionTop + sectionRect.height; + const visibleTop = + scrollRoot.scrollTop + SettingsComponent.SECTION_SCROLL_TOP_GUTTER; + const visibleBottom = + scrollRoot.scrollTop + + scrollRoot.clientHeight - + SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER; + let nextScrollTop = scrollRoot.scrollTop; + + if (sectionTop < visibleTop) { + nextScrollTop = + sectionTop - SettingsComponent.SECTION_SCROLL_TOP_GUTTER; + } else if (sectionBottom > visibleBottom) { + nextScrollTop = + sectionBottom - + scrollRoot.clientHeight + + SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER; + } + + const maxScrollTop = Math.max( + 0, + scrollRoot.scrollHeight - scrollRoot.clientHeight + ); + + scrollRoot.scrollTo({ + top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop), + behavior: 'smooth', + }); + + return scrollRoot; + } + + private openSettingsSnackbar( + message: string, + config: MatSnackBarConfig = {} + ): void { + this.snackBar.open(message, undefined, { + duration: 2000, + horizontalPosition: 'center', + verticalPosition: 'bottom', + panelClass: ['settings-snackbar'], + ...config, + }); + } } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 81f628aae..536485cc8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -307,6 +307,8 @@ "LIVE_NOW": "Live now", "LIVE_STREAM": "Live stream", "TIMESHIFT_AVAILABLE": "Timeshift is available, click to play", + "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archive playback is unavailable", + "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "This provider exposes schedule history for the channel, but does not expose playable archive/catch-up playback.", "EPG_NOT_AVAILABLE_DATE": "Ooops, EPG is not available for the selected date", "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG is not available for this channel.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Please add/change the URL of the EPG list in the settings of the app.", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 1eb5299b8..b872cd6bb 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -122,3 +122,7 @@ textarea, .global-search-overlay .mat-mdc-dialog-container { app-region: no-drag; } + +.mat-mdc-snack-bar-container.settings-snackbar { + margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); +} diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index a17d42fab..658be0933 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -131,6 +131,10 @@ declare global { xtreamCancelSession: ( sessionId: string ) => Promise<{ success: boolean; cancelled: number }>; + xtreamProbeUrl: ( + url: string, + method?: 'GET' | 'HEAD' + ) => Promise<{ status: number; url: string }>; refreshPlaylist: ( payload: PlaylistRefreshPayload ) => Promise; diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index 6c1979484..0b29d9101 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -26,6 +26,7 @@ pnpm nx run xtream-mock-server:serve-with-watch | `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active | | `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active | | `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active | +| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | | `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired | | `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled | @@ -51,6 +52,8 @@ Any other credential pair is auto-generated using a hash of `username:password` | `get_vod_info?vod_id=` | Full movie details | | `get_series_info?series_id=` | Full series info (seasons + episodes) | | `get_short_epg?stream_id=[&limit=N]` | EPG listings for a live channel | +| `get_simple_data_table?stream_id=` | Full per-channel EPG schedule | +| `get_simple_date_table?stream_id=` | Legacy typo alias for full per-channel EPG schedule | ### PWA CORS Proxy Endpoint @@ -95,6 +98,9 @@ curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action= # EPG for stream (direct) curl "http://localhost:3211/player_api.php?username=user1&password=pass1&action=get_short_epg&stream_id=10000" +# Full EPG schedule (direct) +curl "http://localhost:3211/player_api.php?username=epg&password=epg&action=get_simple_data_table&stream_id=10000" + # Via PWA proxy curl "http://localhost:3211/xtream?url=http://localhost:3211&username=user1&password=pass1&action=get_live_categories" ``` @@ -112,6 +118,9 @@ nx e2e web-e2e --grep "@xtream" Test files: `apps/web-e2e/src/xtream.e2e.ts` +Electron Xtream EPG coverage lives in +`apps/electron-backend-e2e/src/xtream-epg.e2e.ts`. + The Playwright tests use `page.route()` to redirect the app's backend proxy calls (`localhost:3000/xtream**`) to the mock server without modifying any application code. @@ -123,6 +132,8 @@ application code. - **Deterministic**: Same credentials → same data every time (seeded faker) - **Cached per session**: Data generated once on first request, reused until `/reset` - **EPG**: Titles and descriptions are base64-encoded (matches real Xtream API) +- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests +- **Timestamp precedence coverage**: The `epg:epg` scenario intentionally shifts raw `start` / `end` strings away from `start_timestamp` / `stop_timestamp` so UI tests can prove timestamps drive rendering - **Stream IDs**: Live 10,000+, VOD 20,000+, Series 30,000+ - **Category IDs**: Live 101+, VOD 201+, Series 301+ diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index eb1dbb120..10af189c0 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -1,6 +1,13 @@ import { faker } from '@faker-js/faker'; import { generateCategories } from './generators/categories.generator.js'; -import { generateLiveStreams, generateEpgListings, RawLiveStream, RawEpgListing } from './generators/live.generator.js'; +import { + buildEpgListing, + buildLiveStream, + generateEpgListings, + generateLiveStreams, + RawEpgListing, + RawLiveStream, +} from './generators/live.generator.js'; import { generateVodStreams, generateVodDetails, RawVodStream, RawVodDetails } from './generators/vod.generator.js'; import { generateSeriesItems, generateSeriesInfo, RawSeriesItem, RawSeriesInfo } from './generators/series.generator.js'; import { RawCategory } from './generators/categories.generator.js'; @@ -14,12 +21,12 @@ export interface PortalData { liveStreams: RawLiveStream[]; vodStreams: RawVodStream[]; seriesItems: RawSeriesItem[]; + epgListingsByStreamId: Map; } const portalCache = new Map(); const vodDetailsCache = new Map(); const seriesInfoCache = new Map(); -const epgCache = new Map(); function generatePortalData(username: string, password: string): PortalData { const scenario = getScenario(username, password); @@ -27,14 +34,24 @@ function generatePortalData(username: string, password: string): PortalData { const { categoryCount, itemsPerCategory, seasonsPerSeries, episodesPerSeason } = scenario; - const liveCategories = generateCategories('live', categoryCount.live); + let liveCategories = generateCategories('live', categoryCount.live); const vodCategories = generateCategories('vod', categoryCount.vod); const seriesCategories = generateCategories('series', categoryCount.series); - const liveStreams = generateLiveStreams(liveCategories, itemsPerCategory); + const epgListingsByStreamId = new Map(); + let liveStreams = generateLiveStreams(liveCategories, itemsPerCategory); const vodStreams = generateVodStreams(vodCategories, itemsPerCategory); const seriesItems = generateSeriesItems(seriesCategories, itemsPerCategory); + if (scenario.epgFixture === 'timezone-focus') { + const timezoneFixture = buildTimezoneFixture(); + liveCategories = timezoneFixture.liveCategories; + liveStreams = timezoneFixture.liveStreams; + timezoneFixture.epgListingsByStreamId.forEach((listings, streamId) => { + epgListingsByStreamId.set(streamId, listings); + }); + } + // Pre-populate series info cache for (const s of seriesItems) { if (!seriesInfoCache.has(s.series_id)) { @@ -42,7 +59,16 @@ function generatePortalData(username: string, password: string): PortalData { } } - return { scenario, liveCategories, vodCategories, seriesCategories, liveStreams, vodStreams, seriesItems }; + return { + scenario, + liveCategories, + vodCategories, + seriesCategories, + liveStreams, + vodStreams, + seriesItems, + epgListingsByStreamId, + }; } export function getPortalData(username: string, password: string): PortalData { @@ -74,16 +100,189 @@ export function getSeriesInfo(username: string, password: string, seriesId: numb return seriesInfoCache.get(seriesId) ?? null; } -export function getEpgListings(streamId: number): RawEpgListing[] { - if (!epgCache.has(streamId)) { - epgCache.set(streamId, generateEpgListings(streamId)); +export function getEpgListings( + username: string, + password: string, + streamId: number +): RawEpgListing[] { + const data = getPortalData(username, password); + if (!data.epgListingsByStreamId.has(streamId)) { + data.epgListingsByStreamId.set(streamId, generateEpgListings(streamId)); } - return epgCache.get(streamId)!; + return data.epgListingsByStreamId.get(streamId)!; +} + +export function getShortEpgListings( + username: string, + password: string, + streamId: number, + limit = 12 +): RawEpgListing[] { + const listings = getEpgListings(username, password, streamId); + const now = Math.floor(Date.now() / 1000); + const currentIndex = listings.findIndex((listing) => { + const stopTimestamp = Number.parseInt(listing.stop_timestamp, 10); + return Number.isFinite(stopTimestamp) && stopTimestamp >= now; + }); + + const startIndex = currentIndex >= 0 ? currentIndex : 0; + return listings.slice(startIndex, startIndex + Math.max(1, limit)); } export function resetAll(): void { portalCache.clear(); vodDetailsCache.clear(); seriesInfoCache.clear(); - epgCache.clear(); +} + +function buildTimezoneFixture(): Pick< + PortalData, + 'liveCategories' | 'liveStreams' | 'epgListingsByStreamId' +> { + const primaryCategoryId = '101'; + const secondaryCategoryId = '102'; + const liveCategories: RawCategory[] = [ + { + category_id: primaryCategoryId, + category_name: 'EPG Focus', + parent_id: 0, + }, + { + category_id: secondaryCategoryId, + category_name: 'Overflow Live', + parent_id: 0, + }, + ]; + + const liveStreams: RawLiveStream[] = [ + buildLiveStream({ + num: 1, + name: 'Timezone News', + streamId: 10_000, + categoryId: primaryCategoryId, + tvArchive: 1, + tvArchiveDuration: 3, + }), + buildLiveStream({ + num: 2, + name: 'Night Sports', + streamId: 10_001, + categoryId: primaryCategoryId, + }), + buildLiveStream({ + num: 3, + name: 'Archive Cinema', + streamId: 10_002, + categoryId: primaryCategoryId, + }), + buildLiveStream({ + num: 4, + name: 'Overflow Live 1', + streamId: 10_003, + categoryId: secondaryCategoryId, + }), + buildLiveStream({ + num: 5, + name: 'Overflow Live 2', + streamId: 10_004, + categoryId: secondaryCategoryId, + }), + ]; + + const epgListingsByStreamId = new Map(); + epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000)); + + return { + liveCategories, + liveStreams, + epgListingsByStreamId, + }; +} + +function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] { + const now = Math.floor(Date.now() / 1000); + const roundedNow = now - (now % (15 * 60)); + const nextUtcMidnight = getFutureUtcMidnight(now); + const rawStringOffsetSeconds = -2 * 60 * 60; + const channelId = `channel-${streamId}.mock`; + + const listings = [ + { + id: `${streamId}-past`, + title: 'Earlier Bulletin', + description: 'Past schedule item used to anchor current-program detection.', + startTimestamp: roundedNow - 75 * 60, + stopTimestamp: roundedNow - 15 * 60, + }, + { + id: `${streamId}-current`, + title: 'Global Headlines', + description: 'Current program for list-row and detail EPG assertions.', + startTimestamp: roundedNow - 15 * 60, + stopTimestamp: roundedNow + 15 * 60, + }, + { + id: `${streamId}-next`, + title: 'Market Wrap', + description: 'Immediate next program for short-EPG preview assertions.', + startTimestamp: roundedNow + 15 * 60, + stopTimestamp: roundedNow + 45 * 60, + }, + { + id: `${streamId}-later`, + title: 'Overnight Update', + description: 'Later same-day program for selected-channel list coverage.', + startTimestamp: roundedNow + 45 * 60, + stopTimestamp: roundedNow + 75 * 60, + }, + { + id: `${streamId}-boundary-1`, + title: 'Late Edition', + description: 'Boundary program spanning UTC midnight for timezone edge cases.', + startTimestamp: nextUtcMidnight - 30 * 60, + stopTimestamp: nextUtcMidnight + 30 * 60, + }, + { + id: `${streamId}-boundary-2`, + title: 'After Midnight', + description: 'Post-midnight program used for next-day navigation checks.', + startTimestamp: nextUtcMidnight + 30 * 60, + stopTimestamp: nextUtcMidnight + 90 * 60, + }, + ]; + + return listings + .sort((left, right) => left.startTimestamp - right.startTimestamp) + .map((listing) => + buildEpgListing({ + id: listing.id, + epgId: channelId, + title: listing.title, + description: listing.description, + startTimestamp: listing.startTimestamp, + stopTimestamp: listing.stopTimestamp, + channelId, + rawStringOffsetSeconds, + }) + ); +} + +function getFutureUtcMidnight(nowSeconds: number): number { + const nowDate = new Date(nowSeconds * 1000); + let midnightSeconds = Math.floor( + Date.UTC( + nowDate.getUTCFullYear(), + nowDate.getUTCMonth(), + nowDate.getUTCDate() + 1, + 0, + 0, + 0 + ) / 1000 + ); + + while (midnightSeconds - nowSeconds < 3 * 60 * 60) { + midnightSeconds += 24 * 60 * 60; + } + + return midnightSeconds; } diff --git a/apps/xtream-mock-server/src/app/generators/live.generator.ts b/apps/xtream-mock-server/src/app/generators/live.generator.ts index 9d19e76f2..e7a6b67ab 100644 --- a/apps/xtream-mock-server/src/app/generators/live.generator.ts +++ b/apps/xtream-mock-server/src/app/generators/live.generator.ts @@ -32,7 +32,32 @@ export interface RawEpgListing { const LIVE_STREAM_ID_BASE = 10_000; const EPG_SLOT_MINUTES = 30; -const EPG_SLOTS = 12; +const EPG_PREVIOUS_DAYS = 2; +const EPG_NEXT_DAYS = 3; +const EPG_SLOTS = + ((EPG_PREVIOUS_DAYS + EPG_NEXT_DAYS) * 24 * 60) / EPG_SLOT_MINUTES; + +type BuildLiveStreamOptions = { + num: number; + name: string; + streamId: number; + categoryId: string; + epgChannelId?: string; + tvArchive?: number; + tvArchiveDuration?: number; +}; + +type BuildEpgListingOptions = { + id: string; + epgId: string; + title: string; + description: string; + startTimestamp: number; + stopTimestamp: number; + channelId: string; + rawStringOffsetSeconds?: number; + lang?: string; +}; export function generateLiveStreams( categories: RawCategory[], @@ -44,21 +69,14 @@ export function generateLiveStreams( for (const cat of categories) { for (let i = 0; i < itemsPerCategory; i++) { const streamId = LIVE_STREAM_ID_BASE + streams.length; - streams.push({ - num: num++, - name: `${faker.company.name()} TV`, - stream_type: 'live', - stream_id: streamId, - stream_icon: `https://picsum.photos/seed/live-${streamId}/100/100`, - epg_channel_id: `channel-${streamId}.mock`, - added: String(Math.floor(Date.now() / 1000 - Math.random() * 1e7)), - category_id: cat.category_id, - custom_sid: '', - direct_source: '', - tv_archive: 0, - tv_archive_duration: 0, - rating_imdb: (Math.random() * 3 + 6).toFixed(1), - }); + streams.push( + buildLiveStream({ + num: num++, + name: `${faker.company.name()} TV`, + streamId, + categoryId: cat.category_id, + }) + ); } } return streams; @@ -68,7 +86,7 @@ export function generateLiveStreams( export function generateEpgListings(streamId: number): RawEpgListing[] { const listings: RawEpgListing[] = []; const nowSec = Math.floor(Date.now() / 1000); - const startOffset = -(EPG_SLOTS / 2) * EPG_SLOT_MINUTES * 60; + const startOffset = -EPG_PREVIOUS_DAYS * 24 * 60 * 60; for (let i = 0; i < EPG_SLOTS; i++) { const startTs = nowSec + startOffset + i * EPG_SLOT_MINUTES * 60; @@ -76,18 +94,85 @@ export function generateEpgListings(streamId: number): RawEpgListing[] { const title = faker.company.catchPhrase(); const description = faker.lorem.sentence(); - listings.push({ - id: String(streamId * 100 + i), - epg_id: `channel-${streamId}.mock`, - title: Buffer.from(title).toString('base64'), - lang: 'en', - start: new Date(startTs * 1000).toISOString().replace('T', ' ').replace('.000Z', ''), - end: new Date(stopTs * 1000).toISOString().replace('T', ' ').replace('.000Z', ''), - description: Buffer.from(description).toString('base64'), - channel_id: `channel-${streamId}`, - start_timestamp: String(startTs), - stop_timestamp: String(stopTs), - }); + listings.push( + buildEpgListing({ + id: String(streamId * 100 + i), + epgId: `channel-${streamId}.mock`, + title, + description, + startTimestamp: startTs, + stopTimestamp: stopTs, + channelId: `channel-${streamId}.mock`, + }) + ); } return listings; } + +export function buildLiveStream( + options: BuildLiveStreamOptions +): RawLiveStream { + const { + num, + name, + streamId, + categoryId, + epgChannelId = `channel-${streamId}.mock`, + tvArchive = 1, + tvArchiveDuration = 3, + } = options; + + return { + num, + name, + stream_type: 'live', + stream_id: streamId, + stream_icon: `https://picsum.photos/seed/live-${streamId}/100/100`, + epg_channel_id: epgChannelId, + added: String(Math.floor(Date.now() / 1000 - Math.random() * 1e7)), + category_id: categoryId, + custom_sid: '', + direct_source: '', + tv_archive: tvArchive, + tv_archive_duration: tvArchiveDuration, + rating_imdb: (Math.random() * 3 + 6).toFixed(1), + }; +} + +export function buildEpgListing( + options: BuildEpgListingOptions +): RawEpgListing { + const { + id, + epgId, + title, + description, + startTimestamp, + stopTimestamp, + channelId, + rawStringOffsetSeconds = 0, + lang = 'en', + } = options; + + return { + id, + epg_id: epgId, + title: Buffer.from(title).toString('base64'), + lang, + start: formatXtreamDateTime( + startTimestamp + rawStringOffsetSeconds + ), + end: formatXtreamDateTime(stopTimestamp + rawStringOffsetSeconds), + description: Buffer.from(description).toString('base64'), + channel_id: channelId, + start_timestamp: String(startTimestamp), + stop_timestamp: String(stopTimestamp), + }; +} + +function formatXtreamDateTime(timestampSeconds: number): string { + return new Date(timestampSeconds * 1000) + .toISOString() + .replace('T', ' ') + .replace('.000Z', ''); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-full-epg.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-full-epg.handler.ts new file mode 100644 index 000000000..d57fa0502 --- /dev/null +++ b/apps/xtream-mock-server/src/app/handlers/get-full-epg.handler.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express'; +import { getEpgListings } from '../data-store.js'; + +export function handleGetFullEpg(req: Request, res: Response): void { + const { + username = '', + password = '', + stream_id, + } = req.query as Record; + if (!stream_id) { + res.json({ epg_listings: [] }); + return; + } + + const id = parseInt(stream_id, 10); + res.json({ epg_listings: getEpgListings(username, password, id) }); +} diff --git a/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts index 95b143ce5..cb53b6d53 100644 --- a/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts +++ b/apps/xtream-mock-server/src/app/handlers/get-short-epg.handler.ts @@ -1,14 +1,19 @@ import { Request, Response } from 'express'; -import { getEpgListings } from '../data-store.js'; +import { getShortEpgListings } from '../data-store.js'; export function handleGetShortEpg(req: Request, res: Response): void { - const { stream_id, limit } = req.query as Record; + const { + username = '', + password = '', + stream_id, + limit, + } = req.query as Record; if (!stream_id) { res.json({ epg_listings: [] }); return; } const id = parseInt(stream_id, 10); const limitNum = limit ? Math.min(parseInt(limit, 10), 50) : 12; - const listings = getEpgListings(id).slice(0, limitNum); + const listings = getShortEpgListings(username, password, id, limitNum); res.json({ epg_listings: listings }); } diff --git a/apps/xtream-mock-server/src/app/routes/dispatch.ts b/apps/xtream-mock-server/src/app/routes/dispatch.ts index 447cbb1cb..992401874 100644 --- a/apps/xtream-mock-server/src/app/routes/dispatch.ts +++ b/apps/xtream-mock-server/src/app/routes/dispatch.ts @@ -4,6 +4,7 @@ import { handleGetLiveCategories, handleGetVodCategories, handleGetSeriesCategor import { handleGetLiveStreams, handleGetVodStreams, handleGetSeriesStreams } from '../handlers/get-streams.handler.js'; import { handleGetVodInfo } from '../handlers/get-vod-info.handler.js'; import { handleGetSeriesInfo } from '../handlers/get-series-info.handler.js'; +import { handleGetFullEpg } from '../handlers/get-full-epg.handler.js'; import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js'; export function dispatchAction(req: Request, res: Response): void { @@ -41,6 +42,10 @@ export function dispatchAction(req: Request, res: Response): void { case 'get_short_epg': handleGetShortEpg(req, res); break; + case 'get_simple_data_table': + case 'get_simple_date_table': + handleGetFullEpg(req, res); + break; default: res.status(400).json({ error: `Unknown action: ${action}` }); } diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 1032872bc..c63762098 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -11,6 +11,8 @@ export interface ScenarioConfig { accountStatus: 'Active' | 'Disabled'; /** ISO date string for subscription expiry */ expiryDate: string; + /** Optional deterministic EPG fixture profile for scenario-specific tests. */ + epgFixture?: 'timezone-focus'; } /** @@ -75,6 +77,19 @@ export const SCENARIOS: Record = { accountStatus: 'Active', expiryDate: '2099-12-31', }, + 'epg:epg': { + name: 'epg-fixture', + description: + 'Deterministic Xtream EPG fixture — stable live channels and timezone-focused guide data', + seed: 6006, + categoryCount: { live: 2, vod: 1, series: 1 }, + itemsPerCategory: 3, + seasonsPerSeries: 1, + episodesPerSeason: 3, + accountStatus: 'Active', + expiryDate: '2099-12-31', + epgFixture: 'timezone-focus', + }, 'expired:expired': { name: 'expired', description: 'Expired account — tests subscription expiry UI flow', diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 2994fb6d0..53fd0fb53 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -45,6 +45,17 @@ app.get('/series/:username/:password/:streamId.:ext', (_req, res) => { res.redirect(HLS_STUB); }); +app.all( + '/timeshift/:username/:password/:duration/:start/:streamId.ts', + (_req, res) => { + res.redirect(HLS_STUB); + } +); + +app.all('/streaming/timeshift.php', (_req, res) => { + res.redirect(HLS_STUB); +}); + // ─── PWA CORS proxy endpoint ──────────────────────────────────────────────────── // IPTVnator PWA routes Xtream calls through: // GET /xtream?url=&action=&username=X&password=Y diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index 0269f5747..177378954 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -56,7 +56,7 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0 **Category Management Dialog** -- Path: `apps/web/src/app/xtream-electron/category-management-dialog/` +- Path: `libs/portal/xtream/feature/src/lib/category-management-dialog/` - Features: - Checkbox list of all categories - Select All / Deselect All buttons @@ -77,7 +77,7 @@ Both components: ### Store -**File**: `apps/web/src/app/xtream-electron/xtream.store.ts` +**File**: `libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts` Added `reloadCategories()` method to refresh categories from database after visibility changes, ensuring the sidebar updates immediately. @@ -96,9 +96,23 @@ When a user refreshes an Xtream playlist, hidden category preferences are preser 2. **Temporary storage**: The hidden categories are stored in `localStorage` under key `xtream-restore-{playlistId}` along with favorites and recently viewed data 3. **During re-import**: When categories are saved via `DB_SAVE_CATEGORIES`, the data source checks `localStorage` for saved hidden category xtreamIds 4. **Restoration**: Categories matching the saved xtreamIds are inserted with `hidden = true`, preserving the user's visibility preferences +5. **ID normalization**: Xtream category IDs arrive from the API as strings, while SQLite stores `categories.xtream_id` as an integer. Restoration must normalize incoming `category_id` values before matching them against saved hidden-category xtreamIds. This ensures that users don't lose their category visibility customizations when refreshing playlists to get updated content. +### Debugging Note + +Hidden-category restoration runs through the Electron DB worker. When debugging +or validating a fix in a live Electron app: + +1. rebuild the worker-backed Electron runtime +2. restart the running Electron process +3. reconnect `agent-browser --cdp 9222` + +Otherwise the app may still be using an older +`dist/apps/electron-backend/workers/database.worker.js` bundle even though the +TypeScript source has already been updated. + ## Files Changed ``` @@ -117,20 +131,21 @@ libs/services/src/lib/ libs/ui/components/src/lib/recent-playlists/ └── recent-playlists.component.ts # Stores hidden categories to localStorage on refresh -apps/web/src/app/xtream-electron/ -├── category-management-dialog/ # Dialog component +libs/portal/xtream/feature/src/lib/ +├── category-management-dialog/ # Dialog component │ ├── category-management-dialog.component.ts │ ├── category-management-dialog.component.html │ └── category-management-dialog.component.scss -├── data-sources/ -│ └── electron-xtream-data-source.ts # Reads/passes hidden categories on save -├── xtream-main-container.component.ts # Added button & dialog +├── xtream-main-container.component.ts # Added button & dialog ├── xtream-main-container.component.html ├── live-stream-layout/ -│ ├── live-stream-layout.component.ts # Added button & dialog +│ ├── live-stream-layout.component.ts # Added button & dialog │ └── live-stream-layout.component.html -├── sidebar.scss # Updated header styles -└── xtream.store.ts # Added reloadCategories method + +libs/portal/xtream/data-access/src/lib/ +├── data-sources/ +│ └── electron-xtream-data-source.ts # Reads/passes hidden categories on save +└── stores/xtream.store.ts # Added reloadCategories method apps/web/src/assets/i18n/ └── en.json # Added translation keys diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 997cc3b8b..20dd78b94 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -15,7 +15,7 @@ The download manager is a desktop-only feature that layers a curated queue, prog - **Downloads service** (`apps/web/src/app/services/downloads.service.ts`) Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download the service resolves a download folder (stored in `SettingsStore` or fetched via `downloadsGetDefaultFolder`) and calls `downloadsStart`. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. -- **Downloads view** (`apps/web/src/app/xtream-electron/downloads`) +- **Downloads view** (`libs/portal/downloads/feature`) A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` now wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives a bold two-tone aesthetic inspired by the frontend-design mandate—gradient cards, floating avatars, and theme-aware variables triggered via `body.dark-theme`. Failed/canceled cards now show retry/delete controls, queued/downloading cards show a cancel icon, and completed cards render inline play/open buttons with `mat-icon` cues. The header also shows the resolved download folder and a `CHANGE FOLDER` action. - **Theme fixes** diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md new file mode 100644 index 000000000..92c0cdc09 --- /dev/null +++ b/docs/architecture/embedded-inline-playback.md @@ -0,0 +1,124 @@ +# Embedded Inline Playback + +This document records the current contract for embedded playback in portal detail views. + +## Summary + +- Embedded web players are `videojs`, `html5`, and `artplayer`. +- External players are `mpv` and `vlc`. +- Live playback stays inline in dedicated live layouts. +- VOD and series detail playback now also stays inline on canonical detail surfaces. +- Material dialog playback remains only as a fallback for older non-detail callers. + +## Scope + +The first pass is intentionally limited: + +- Xtream VOD detail route +- Xtream series detail route +- Stalker VOD detail view +- Stalker series detail view + +Not migrated in this pass: + +- Generic non-detail playback entry points that still call `PlayerService.openPlayer(...)` +- Any collection/search surface that does not host a canonical detail surface of its own + +## Components + +Shared inline player shell: + +- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/portal-inline-player/portal-inline-player.component.ts` + +Xtream detail hosts: + +- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` + +Stalker detail hosts: + +- `/Users/4gray/Code/iptvnator/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts` +- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts` + +Fallback dialog path: + +- `/Users/4gray/Code/iptvnator/apps/web/src/app/services/player.service.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts` + +## Playback Decision Rule + +When a detail view starts playback: + +1. Resolve or construct a typed playback payload. +2. Check the active player setting. +3. If the player is embedded, render the inline player inside the current detail view. +4. If the player is external, hand the same payload to `PlayerService` for MPV/VLC playback. + +The detail host owns inline state. `PlayerService` is no longer the primary owner of UI playback state for canonical VOD/series detail screens. + +## Typed Playback Payload + +Shared playback payloads live in: + +- `/Users/4gray/Code/iptvnator/libs/shared/interfaces/src/lib/portal-playback.interface.ts` + +Types introduced: + +- `PlayerContentInfo` +- `ResolvedPortalPlayback` + +These provide a single shape for: + +- `streamUrl` +- `title` +- optional thumbnail and resume start time +- playback-position metadata +- optional external-player headers and request metadata + +## Xtream Behavior + +Xtream detail views already own canonical routes, so they construct playback locally and decide inline vs external locally. + +Behavior to preserve: + +- resume/playback position continues saving from `timeUpdate` +- back navigation clears inline playback with the route +- favorites, recent, and search still route into canonical Xtream detail screens before playback + +## Stalker Behavior + +Stalker previously resolved playback and opened UI in the same method. + +Current contract: + +- `resolveVodPlayback(...)` returns a `ResolvedPortalPlayback` +- `createLinkToPlayVod(...)` remains as a compatibility wrapper for untouched callers +- canonical Stalker detail views use the resolver directly and decide inline vs external locally + +This keeps: + +- inline/store-state detail navigation intact +- series and VOD-as-series support intact +- non-detail callers working until they are migrated + +## Playback Position Saving + +The old dialog path saved playback positions from inside `PlayerDialogComponent`. + +The new contract is: + +- inline detail hosts listen to `timeUpdate` +- each host throttles saves +- each host persists via existing playback-position infrastructure + +This avoids coupling inline UI state to a global dialog. + +## Future Migration Rule + +If a non-detail surface is converted away from dialog playback: + +- give that surface a canonical inline host +- switch it to `ResolvedPortalPlayback` +- do not move portal-specific navigation into `PlayerService` + +The preferred direction is view-owned inline playback, not a larger dialog manager. diff --git a/docs/architecture/external-wiki-sync.md b/docs/architecture/external-wiki-sync.md new file mode 100644 index 000000000..ffbf00400 --- /dev/null +++ b/docs/architecture/external-wiki-sync.md @@ -0,0 +1,97 @@ +# External Wiki Sync + +Related: + +- [Workspace Shell](./workspace-shell.md) +- [SQLite DB Worker](./sqlite-db-worker.md) + +## Summary + +- Repo docs are canonical, even when they were originally drafted by an LLM. +- The external Obsidian wiki imports canonical repo docs read-only into `_repo-context/`. +- Higher-level synthesis pages stay outside `_repo-context/` in the wiki's own folders. +- Sync is one-way by default: repo docs -> wiki context. + +## Ownership Model + +Canonical repo docs include: + +1. `docs/architecture/**/*.md` +2. top-level workflow docs such as `README.md`, `GETTING-STARTED.md`, `AGENTS.md`, and `CLAUDE.md` +3. selected module `README.md` files when they describe current code behavior or workflows + +The external wiki can add cross-links, feature pages, decision notes, and synthesis pages, but it must not become a second source of truth for the same implementation details. + +## Export Scope + +The repo-owned exporter writes only to `_repo-context/` inside the external vault. + +Current default export scope: + +1. `docs/architecture/**/*.md` +2. `README.md` +3. `GETTING-STARTED.md` +4. `AGENTS.md` +5. `CLAUDE.md` +6. selected module `README.md` files + +The exporter also generates: + +1. `_repo-context/index.md` +2. `_repo-context/repo-map.md` +3. `_repo-context/recent-changes.md` +4. `_repo-context/manifest.json` +5. `_repo-context/state.json` + +## Running The Exporter + +Set the external vault path in the shell environment: + +```bash +export IPTVNATOR_WIKI_VAULT=/absolute/path/to/your/obsidian-vault +``` + +Then run: + +```bash +pnpm wiki:export --mode full +pnpm wiki:export --mode changed +``` + +You can also override the vault path per command: + +```bash +pnpm wiki:export --mode changed --vault /absolute/path/to/your/obsidian-vault +``` + +If the vault path is missing, the exporter skips cleanly and reports why it did not run. + +## Agent Workflow After Changes + +After a meaningful implementation change, agents must assess whether canonical repo docs need updates. + +Documentation-worthy changes include: + +1. new or changed user-visible behavior +2. architecture or data-flow changes +3. non-obvious maintenance workflows +4. new setup, debugging, or operational steps +5. new subsystem contracts or boundaries + +Prefer updating an existing authoritative doc before creating a new one: + +1. `README.md` for top-level developer or user workflows +2. `docs/architecture/` for architecture, ownership, and behavior contracts +3. the nearest module `README.md` for local usage or behavior + +If docs changed and `IPTVNATOR_WIKI_VAULT` is configured, agents should run `pnpm wiki:export --mode changed` before considering the task complete. + +## Promotion Workflow + +If a wiki page becomes stable enough to be canonical: + +1. promote that content back into a repo doc +2. treat the repo doc as the source of truth +3. export again so `_repo-context/` reflects the promoted canonical doc + +The wiki page can then either link to the repo-backed generated page or remain as a smaller synthesis page that references the canonical doc. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md new file mode 100644 index 000000000..1ead04c24 --- /dev/null +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -0,0 +1,252 @@ +# IPTVnator UI Guidelines + +This document captures the current UI language used across IPTVnator, with emphasis on channel lists, EPG views, settings surfaces, and shared selection patterns. + +Use it when changing existing views or introducing new list-based UI in the workspace, Xtream, or Stalker flows. + +## Core Principles + +1. Prefer shared components over duplicated markup. + The canonical channel row is `app-channel-list-item`. + +2. Drive emphasis through selection state, not through constant decoration. + Neutral rows should stay quiet. Only active or current items should pick up strong color. + +3. Use the same selection language everywhere. + Selected nav items, channels, and current EPG cards should feel like the same system. + +4. Keep dark and light themes intentionally different. + Dark theme can carry more density and tinted surfaces. + Light theme should be flatter and cleaner, with white or near-white cards. + +5. Scroll ownership must be explicit. + Headers stay visible. Lists scroll. Do not let nested panes compete for scroll. + +## Canonical References + +- Channel row: + `libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html` +- Channel row styles: + `libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss` +- Shared EPG pane: + `libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html` +- Shared EPG pane styles: + `libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss` +- Shared list selection style: + `apps/web/src/nav-list.scss` +- Theme tokens: + `apps/web/src/m3-theme.scss` +- Settings surfaces: + `apps/web/src/app/settings/settings.component.scss` + +## Shared Tokens + +These tokens are the base for interactive emphasis: + +- `--app-selection-color` +- `--app-selection-surface` +- `--app-selection-surface-strong` +- `--app-selection-border` +- `--app-selection-glow` + +Use Material surface tokens for neutral surfaces: + +- `--mat-sys-surface` +- `--mat-sys-surface-container-low` +- `--mat-sys-surface-container` +- `--mat-sys-surface-container-high` +- `--mat-sys-outline-variant` +- `--mat-sys-on-surface` +- `--mat-sys-on-surface-variant` + +Do not hardcode unrelated accent colors for selected state when these tokens already exist. + +## Selection Pattern + +Apply the same visual recipe to selected list items, active channels, and current EPG items: + +- Background: + `linear-gradient(135deg, var(--app-selection-surface-strong), var(--app-selection-surface))` +- Border: + `var(--app-selection-border)` +- Glow: + outer shadow using `var(--app-selection-glow)` +- Lift: + `transform: translateY(-1px)` for selected list items only +- Text: + selected text should inherit `var(--app-selection-color)` + +Use this pattern for: + +- `.nav-item.selected` / `.nav-item.active` +- `.channel-list-item.active` +- `.epg-item.current-program` + +Do not add extra badges, left rails, or second selection systems unless there is a strong reason. + +## Channel List Item + +The shared row should be reused instead of rebuilding channel markup per view. + +### Structure + +- Min height: + `68px` +- Horizontal gap: + `12px` +- Padding: + `8px 10px 8px 12px` +- Radius: + `12px` +- Logo shell: + `44x44`, rounded, subtle inset treatment +- Compact variant: + `52px` min height with slightly tighter padding + +### Content Layout + +- Title is one line, medium-bold, slightly condensed +- Program title is a secondary line with lower emphasis +- Timeline uses three columns: + start time, progress bar, end time +- Action buttons sit on the trailing edge and inherit row color + +### Logo Rules + +- Show fallback icon only when no image is available or image loading fails +- Do not render placeholder and real logo at the same time +- Keep logos contained with `object-fit: contain` + +## EPG Views + +### Shared EPG Pane + +- Header title stays sticky +- Program list is the only scrolling region +- Add bottom padding so the last program is not clipped +- Current program card uses the same selection treatment as selected channels + +### EPG Card + +- Radius: + `14px` +- Neutral cards use low-contrast surface treatment +- Current card uses selection surface and selection border +- Description should clamp rather than overflow + +### Sticky Header + +- Keep the title readable above content +- Use a solid or near-solid backing surface +- Do not let it overlap or cover player controls + +## Progress Bars + +Channel preview progress and EPG current-program progress should stay visually aligned. + +### Track + +- Height: + `6px` +- Shape: + full pill radius +- Neutral background: + medium gray or neutral surface tint +- Include a slight inset edge so the remaining duration is visible + +### Fill + +- Use `--app-selection-color` +- Add a subtle sheen, not a heavy gradient +- Add a restrained glow, not a neon effect + +The progress bar should clearly communicate: + +- completed duration +- remaining duration + +Avoid making the track too faint, especially in dark theme. + +## Navigation Lists + +Use the shared `nav-list.scss` treatment for sidebar and context-panel list items. + +### Rules + +- Keep labels one line with ellipsis +- Keep icon area clear from the selection border and any decorative rail +- Hover is neutral surface, not the selected color +- Selected state uses the shared selection recipe + +If the label is too long for the rail, shorten the label key instead of shrinking the component until it becomes inconsistent. + +## Settings Surfaces + +Settings use the same system but are flatter than content-heavy views. + +### Light Theme + +- Prefer white or near-white cards +- Use neutral borders from `--mat-sys-outline-variant` +- Keep active sections mostly defined by outline and subtle tint +- Avoid dark translucent backgrounds + +### Dark Theme + +- Denser tinted surfaces are acceptable +- Neutral rows can use low-opacity dark overlays +- Keep strong blue tint reserved for active sections and selected items + +## Theme Guidance + +### Light Theme + +- Flat beats glossy +- White and surface-container layers should separate content +- Selection should read as a blue outline plus soft tint, not a solid slab + +### Dark Theme + +- Slight translucency is acceptable +- Background layers can be deeper and more cinematic +- Keep contrast readable without going pure white everywhere + +## Reuse Strategy + +Before creating new markup or CSS: + +1. Check whether `app-channel-list-item` can be reused. +2. Check whether `app-epg-view` already provides the correct structure. +3. Check whether `nav-list.scss` already solves the list-selection problem. +4. Extend tokens first, duplicate styles last. + +## Implementation Workflow + +When updating IPTVnator UI: + +1. Inspect the current shared component first. +2. Reuse the shared structure where possible. +3. Keep selection, progress, and spacing in sync across Xtream, Stalker, and shared portal views. +4. Verify in both light and dark themes. +5. Verify in the running Electron app when the change is visual or layout-sensitive. + +## Anti-Patterns + +Avoid these: + +- introducing a new selected-state color unrelated to the theme tokens +- duplicating channel row markup in portal-specific views +- showing placeholder logos behind real logos +- making entire panes scroll when only the list should scroll +- using dark translucent fills unchanged in light theme +- solving cramped sidebars with smaller fonts instead of shorter labels + +## Definition Of Done For UI Changes + +A visual change is not done until: + +1. Shared component reuse was considered first. +2. Light theme and dark theme both look intentional. +3. Selection and progress states match existing IPTVnator patterns. +4. Scroll behavior is correct. +5. The result was checked in the running app for layout-sensitive work. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index d677f704d..eece9b227 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -16,7 +16,7 @@ The M3U playlist module provides: ``` ┌─────────────────────────────────────────────────────────────────────┐ │ VIDEO PLAYER PAGE │ -│ apps/web/src/app/home/video-player/ │ +│ libs/playlist/m3u/feature-player/src/lib/video-player/ │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌─────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │ │ │ Sidebar │ │ Video Player │ │ EPG List │ │ @@ -237,7 +237,7 @@ class EpgService { ## Video Player -**Location**: `apps/web/src/app/home/video-player/` +**Location**: `libs/playlist/m3u/feature-player/src/lib/video-player/` ### Supported Players - **ArtPlayer** (default) - Modern player with plugins diff --git a/docs/architecture/navigation-ux-analysis.md b/docs/architecture/navigation-ux-analysis.md new file mode 100644 index 000000000..4b9910cad --- /dev/null +++ b/docs/architecture/navigation-ux-analysis.md @@ -0,0 +1,119 @@ +# UX/UI Analysis: Header vs Rail Navigation + +Date: 2026-03-22 + +## Overview + +Evaluation of IPTVnator's navigation architecture — specifically the separation between **global actions in the top header** and **playlist-local actions in the left rail sidebar**, assessed from a user understanding perspective. + +## Current Architecture + +| Region | Intended Scope | Actual Contents | +|--------|---------------|-----------------| +| **Header** (top) | Global / app-wide | Playlist switcher, search, add playlist, global favorites, downloads, **context menu with local actions** | +| **Rail** (left) | Local / playlist-specific | Dashboard (global), Sources (global), **dynamic provider links** (local), Settings (global) | + +Neither region is purely global or purely local. Both mix scopes, which muddies the mental model. + +## Strengths + +- **Playlist switcher in the header** is excellent placement. Acts like a "workspace context selector" — similar to Slack's workspace switcher or VS Code's project selector. +- **Command palette** nails the global-vs-local distinction with explicit "GLOBAL ACTIONS" and "THIS PLAYLIST" section headers. Clearest articulation of scope in the entire UI. +- **Rail dividers** between static workspace links (Dashboard, Sources) and dynamic provider links provide a subtle visual boundary hinting at the scope change. +- **Search bar adapting its placeholder text** per route is good contextual affordance. +- **Settings at the rail bottom** follows a well-established pattern (Slack, Discord, VS Code). + +## Confusion Points + +### A. Rail Mixes Global and Local Without Explaining Why + +When a user selects an Xtream playlist, the rail shows: +``` +Dashboard <- global +Sources <- global +----------------- +Movies <- local (Xtream) +Live TV <- local (Xtream) +Series <- local (Xtream) +----------------- +Search <- local (Xtream) +Recently viewed <- local (Xtream) +Favorites <- local (Xtream) +----------------- +Settings <- global +``` + +When switching to M3U: +``` +Dashboard <- global +Sources <- global +----------------- +All channels <- local (M3U) +Groups <- local (M3U) +Recently viewed <- local (M3U) +Favorites <- local (M3U) +----------------- +Settings <- global +``` + +**Issue:** The dynamic links change silently. There's no label like "rucolor.tv" or "clean.m3u" above the provider links to indicate *which* playlist these links belong to. Users who switch playlists via the header dropdown may not immediately notice the rail updated. + +**Severity:** Medium. + +### B. Header's Three-Dot Menu Breaks the "Global Header" Mental Model + +The context actions menu in the header contains: +- **Playlist Info** — local to the current playlist +- **Account Info** — local to the current Xtream portal +- **Clear Recently Viewed** — local bulk action + +These are playlist-scoped actions living in what should be the "global" header area. + +**Severity:** Low-Medium. + +### C. "Favorites" Appears in Both Global and Local Contexts + +- **Header:** Global Favorites star icon (cross-playlist) +- **Rail:** Favorites link (playlist-specific) + +A user clicking the star in the header vs the heart in the rail gets *different* favorites views with *no* clear labeling of "global" vs "this playlist." + +**Severity:** Medium-High. Most likely source of user confusion. + +### D. Search Bar Scope Is Invisible + +The search bar disables itself on some routes and changes behavior on others. The placeholder text changes, but "Search in this section..." doesn't clarify *which* section. + +**Severity:** Low. + +## Recommendations + +### Quick Wins (Low Effort, High Impact) + +1. **Add a playlist name label above the dynamic rail links.** Small, muted text showing "clean.m3u" or "rucolor.tv" above the provider-specific navigation. +2. **Differentiate the Global Favorites icon from Local Favorites.** Use different icons, tooltip distinction, or a small globe badge on the global favorites star. +3. **Add a scope label to the search bar** when active: "Searching in Live TV" or "Searching in clean.m3u" instead of generic "Search in this section..." + +### Medium Effort + +4. **Consider moving the three-dot context menu into the context panel** rather than the header, keeping the header purely global. +5. **Animate the rail transition** when switching playlists — a subtle slide or fade to signal that links changed. + +## Overall Assessment + +**Score: 7/10 — Good, with clear improvement opportunities.** + +The architecture follows patterns users will recognize from Slack, VS Code, and Spotify. The main risks are the **silent dynamic rail** and the **favorites scope ambiguity**. Fixing those two issues would bring this to a 9/10 for navigational clarity. + +### Design Principle + +The command palette already has the right model: **explicit scope labels**. Apply this same principle to the rail and header. Anywhere an action's scope isn't obvious from its placement, label it. + +## Key Files + +- `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html` +- `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` +- `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts` +- `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` +- `libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts` +- `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts` diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md new file mode 100644 index 000000000..c3537adfc --- /dev/null +++ b/docs/architecture/portal-detail-navigation.md @@ -0,0 +1,81 @@ +# Portal Detail Navigation + +This document records the current navigation contract for Xtream and Stalker detail flows, especially for favorites, recently viewed, search, and category content. + +Related: + +- [Embedded Inline Playback](./embedded-inline-playback.md) + +## Summary + +- Xtream uses a route-first detail model. +- Stalker uses an inline/store-state detail model. +- Keep favorites/recent/search behavior aligned with the canonical detail model of the same portal. +- Do not force both portals into the same behavior unless the full portal detail architecture is being changed. + +## Xtream + +Xtream details are represented by canonical routes. + +Examples: +- `/xtreams/:id/vod/:categoryId/:vodId` +- `/xtreams/:id/series/:categoryId/:serialId` + +Implication: +- Favorites, recently viewed, and search should redirect to the original Xtream content route and item route. +- This keeps the URL, browser history, and detail rendering model aligned with normal Xtream browsing. + +Current code paths: +- `libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts` +- `libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts` +- `libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts` + +Behavior to preserve: +- Selecting an Xtream item from favorites/recent/search should navigate to the Xtream content type/category/item route when the item is not a live stream. +- Live streams can still open through the player path rather than a detail route. + +## Stalker + +Stalker details are represented by store state and inline detail rendering on the current screen. + +Examples: +- Category content sets `selectedItem` and renders details inline. +- Search sets `selectedItem` and stays on the search view. +- Favorites and recently viewed stay on their current collection screen and open inline detail. + +Implication: +- Favorites, recently viewed, and search should remain in the current Stalker view when opening VOD/series details. +- This keeps Stalker behavior aligned with its normal category-content and search flow. + +Current code paths: +- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` +- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` +- `libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts` (Stalker branch) + +Behavior to preserve: +- Favorites/recent/search should not navigate away to a canonical Stalker detail route because Stalker does not currently use one. +- ITV/live items can still trigger playback immediately. + +## Decision Rule For Future Changes + +When deciding how a favorites/recent/search click should behave: + +1. Follow the portal's canonical detail model. +2. Prefer local consistency within the portal over cross-portal sameness. +3. Only unify Xtream and Stalker behavior if the full detail architecture is being unified as well. + +That means: +- Xtream: navigate to the canonical route. +- Stalker: stay in the current screen and open inline detail. + +## Refactor Guidance + +If a future change proposes that Stalker favorites/recent should deep-link into category routes: +- also update Stalker category-content and search behavior +- define a canonical Stalker detail route model first +- update architecture docs and portal skills together + +If a future change proposes that Xtream favorites/recent should stay inline: +- also replace Xtream route-based detail pages with a portal-local inline detail model +- verify history/back behavior and deep links still make sense diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 83c77b94d..28f647c90 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -99,7 +99,7 @@ Type definitions: ## Shared helpers -- File: `apps/web/src/app/shared/services/remote-channel-navigation.util.ts` +- File: `libs/portal/shared/util/src/lib/remote-channel-navigation.ts` Functions: @@ -110,7 +110,7 @@ Used by M3U, Xtream, and Stalker live integrations. ## M3U integration -- File: `apps/web/src/app/home/video-player/video-player.component.ts` +- File: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` Implemented behavior: @@ -133,7 +133,7 @@ Implemented behavior: ## Xtream integration (live view) -- File: `apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.ts` +- File: `libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts` Implemented behavior: @@ -156,7 +156,7 @@ Implemented behavior: ## Stalker integration (ITV live view) -- File: `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` +- File: `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` Implemented behavior: diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md new file mode 100644 index 000000000..e6d14446a --- /dev/null +++ b/docs/architecture/sqlite-db-worker.md @@ -0,0 +1,509 @@ +# SQLite DB Worker + +This document records the current non-EPG SQLite worker implementation in the +Electron app. + +Related: + +- [Category Management](./category-management.md) +- [Workspace Shell](./workspace-shell.md) + +## Summary + +- Heavy non-EPG SQLite work no longer runs on Electron's main thread. +- A dedicated long-lived database worker now handles the slow Xtream and + playlist database operations that were freezing the UI. +- Renderer APIs stay stable. The main change is that progress and long-running + state now flow through a request-scoped `DB_OPERATION_EVENT` contract instead + of a single global progress event. + +## Goals + +The worker cutover addresses three concrete problems: + +1. Main-process UI stalls during large SQLite operations. +2. Xtream import progress events were global and unsafe for concurrent jobs. +3. EPG and non-EPG writers needed shared SQLite concurrency settings so they + can coexist without `SQLITE_BUSY` regressions. + +## Current Ownership + +### Main-process runtime wiring + +These files own worker lifecycle and IPC bridging: + +1. `apps/electron-backend/src/app/services/database-worker-client.ts` +2. `apps/electron-backend/src/app/events/database/category.events.ts` +3. `apps/electron-backend/src/app/events/database/content.events.ts` +4. `apps/electron-backend/src/app/events/database/playlist.events.ts` +5. `apps/electron-backend/src/app/events/database/xtream.events.ts` +6. `apps/electron-backend/src/main.ts` + +### Worker runtime + +These files own the worker protocol and the SQLite work itself: + +1. `apps/electron-backend/src/app/workers/database-worker.types.ts` +2. `apps/electron-backend/src/app/workers/database.worker.ts` +3. `apps/electron-backend/src/app/workers/database.worker-connection.ts` +4. `apps/electron-backend/src/app/workers/worker-runtime-paths.ts` + +### Pure database operation modules + +Keep SQL-heavy logic here so the worker entry remains a thin dispatcher: + +1. `apps/electron-backend/src/app/database/operations/category.operations.ts` +2. `apps/electron-backend/src/app/database/operations/content.operations.ts` +3. `apps/electron-backend/src/app/database/operations/playlist.operations.ts` +4. `apps/electron-backend/src/app/database/operations/xtream.operations.ts` + +## Worker Architecture + +### Request flow + +1. Renderer calls the existing preload API such as `window.electron.dbSaveContent`. +2. `ipcMain.handle(...)` in the Electron backend builds a payload and delegates + to `DatabaseWorkerClient`. +3. `DatabaseWorkerClient` lazily starts one long-lived `worker_threads` worker + and correlates requests with a generated `requestId`. +4. The worker executes SQLite work and sends back either: + 1. `ready` + 2. `event` + 3. `response` +5. The main process resolves the IPC request and forwards worker events back to + the originating renderer process. + +### Why one long-lived worker + +- It avoids worker startup cost on every search/delete/import. +- It centralizes failure handling and restart behavior. +- It mirrors the existing EPG worker approach without multiplying writable + SQLite owners. + +### Packaged worker bootstrap + +Packaged Electron builds do not load worker scripts and native modules from the +same place: + +1. worker scripts live under `Resources/dist/apps/electron-backend/workers` +2. unpacked native modules live under one of the approved + `app.asar.unpacked/.../node_modules` locations + +Both the EPG worker and the DB worker now share the same runtime helper: + +1. `resolveWorkerRuntimeBootstrap(...)` for main-process worker launch +2. `loadNativeModuleFromSearchPaths(...)` for worker-side native module loading + +The helper uses `process.resourcesPath` as the primary packaged base and keeps +`path.dirname(app.getAppPath())` only as a fallback. + +## Worker Message Contract + +The worker contract lives in +`apps/electron-backend/src/app/workers/database-worker.types.ts`. + +### Core message types + +1. `DbWorkerRequestMessage` +2. `DbWorkerResponseMessage` +3. `DbWorkerEventMessage` +4. `DbOperationEvent` + +### Progress event contract + +The worker now emits request-scoped events with: + +- `operationId` +- `operation` +- `playlistId` +- `status` +- optional `phase` +- optional `current` +- optional `total` +- optional `increment` + +Current shipped operation names: + +1. `save-content` +2. `delete-xtream-content` +3. `restore-xtream-user-data` +4. `delete-playlist` +5. `delete-all-playlists` + +The event is forwarded to the renderer as `DB_OPERATION_EVENT`. + +### Cancellation contract + +Long-running Xtream and playlist operations now support best-effort +cancellation. + +Renderer requests cancellation via: + +1. `DB_CANCEL_OPERATION` +2. `window.electron.dbCancelOperation(operationId)` +3. `DatabaseService.cancelOperation(operationId)` + +If a worker operation is canceled: + +1. the worker emits a final `cancelled` event +2. the request rejects with an `AbortError` +3. the UI clears its busy state without treating the operation as success + +Cancellation is cooperative and chunk-based. Already committed SQLite batches +stay committed. + +## Renderer Contract + +The preload bridge keeps the existing database methods but adds scoped worker +events. + +### Important preload APIs + +1. `onDbOperationEvent(callback)` +2. `dbSaveContent(playlistId, streams, type, operationId?)` +3. `dbDeleteXtreamContent(playlistId, operationId?)` +4. `dbRestoreXtreamUserData(..., operationId?)` +5. `dbDeletePlaylist(playlistId, operationId?)` +6. `dbDeleteAllPlaylists(operationId?)` +7. `dbCancelOperation(operationId)` +3. legacy compatibility: + 1. `onDbSaveContentProgress(callback)` + 2. `removeDbSaveContentProgress()` + +`DatabaseService.saveXtreamContent(...)` now generates an `operationId`, +subscribes to `onDbOperationEvent`, filters by that `operationId`, and only +falls back to the legacy progress API if the newer event channel is missing. + +`DatabaseService` also owns: + +1. `createOperationId(...)` +2. `cancelOperation(operationId)` +3. `isDbAbortError(error)` + +## Migrated Operations + +The worker now owns all heavy non-EPG SQLite paths plus the remaining portal +state handlers that still used direct main-thread SQLite access. + +### Categories + +1. `DB_HAS_CATEGORIES` +2. `DB_GET_CATEGORIES` +3. `DB_SAVE_CATEGORIES` +4. `DB_GET_ALL_CATEGORIES` +5. `DB_UPDATE_CATEGORY_VISIBILITY` + +### Content + +1. `DB_HAS_CONTENT` +2. `DB_GET_CONTENT` +3. `DB_SAVE_CONTENT` +4. `DB_GET_CONTENT_BY_XTREAM_ID` +5. `DB_SEARCH_CONTENT` +6. `DB_GLOBAL_SEARCH` +7. `DB_GET_GLOBAL_RECENTLY_ADDED` + +### Playlist metadata + +1. `DB_CREATE_PLAYLIST` +2. `DB_UPSERT_APP_PLAYLIST` +3. `DB_UPSERT_APP_PLAYLISTS` +4. `DB_GET_APP_PLAYLISTS` +5. `DB_GET_APP_PLAYLIST` +6. `DB_GET_PLAYLIST` +7. `DB_UPDATE_PLAYLIST` +8. `DB_DELETE_PLAYLIST` +9. `DB_DELETE_ALL_PLAYLISTS` +10. `DB_GET_APP_STATE` +11. `DB_SET_APP_STATE` + +### Xtream refresh helpers + +1. `DB_DELETE_XTREAM_CONTENT` +2. `DB_RESTORE_XTREAM_USER_DATA` + +### Favorites + +1. `DB_ADD_FAVORITE` +2. `DB_REMOVE_FAVORITE` +3. `DB_IS_FAVORITE` +4. `DB_GET_FAVORITES` +5. `DB_GET_GLOBAL_FAVORITES` +6. `DB_GET_ALL_GLOBAL_FAVORITES` +7. `DB_REORDER_GLOBAL_FAVORITES` + +### Recently viewed + +1. `DB_GET_RECENTLY_VIEWED` +2. `DB_CLEAR_RECENTLY_VIEWED` +3. `DB_GET_RECENT_ITEMS` +4. `DB_ADD_RECENT_ITEM` +5. `DB_CLEAR_PLAYLIST_RECENT_ITEMS` +6. `DB_REMOVE_RECENT_ITEM` + +### Playback positions + +1. `DB_SAVE_PLAYBACK_POSITION` +2. `DB_GET_PLAYBACK_POSITION` +3. `DB_GET_SERIES_PLAYBACK_POSITIONS` +4. `DB_GET_RECENT_PLAYBACK_POSITIONS` +5. `DB_GET_ALL_PLAYBACK_POSITIONS` +6. `DB_CLEAR_PLAYBACK_POSITION` + +## SQLite Concurrency Rules + +EPG remains on its own worker, so both workers must use compatible SQLite +pragmas. + +Applied now in both the shared connection path and worker-owned connections: + +1. `foreign_keys = ON` +2. `journal_mode = WAL` +3. `busy_timeout = 5000` + +Current sources: + +1. `libs/shared/database/src/lib/connection.ts` +2. `apps/electron-backend/src/app/workers/database.worker-connection.ts` +3. `apps/electron-backend/src/app/workers/epg-parser.worker.ts` + +## UI Behavior Changes + +### Search + +Xtream search now guards against stale async responses: + +1. local playlist search uses a monotonically increasing request version +2. global search uses a separate request version in the dialog component +3. clearing search invalidates older pending results + +This prevents an older worker response from repainting over a newer query or a +cleared search state. + +### Busy states + +The UI now has explicit long-running state for destructive operations: + +1. recent playlist rows show row-level refresh/delete spinners +2. Xtream import overlay shows phase text and a cancel action +3. Xtream playlist rows show request-scoped progress and cancel actions +4. busy rows block repeat clicks while an operation is in flight +5. settings "remove all playlists" owns its own spinner/disabled state + +These changes matter because once SQLite work leaves the main thread, the +renderer can actually paint the loading state instead of freezing. + +## Build And Packaging Notes + +### Worker bundling + +`apps/electron-backend/build-worker.js` now bundles both: + +1. `epg-parser.worker.ts` +2. `database.worker.ts` + +The worker build also aliases: + +1. `database-schema` +2. `database-path-utils` + +These aliases avoid importing the shared database barrel from inside the worker, +which would otherwise pull in runtime code that assumes the main Electron +process environment. + +### Worker path resolution + +`DatabaseWorkerClient` resolves: + +1. development path from `__dirname` +2. packaged path from `process.resourcesPath/dist/apps/electron-backend/workers/...` +3. fallback packaged path from `path.dirname(app.getAppPath())` + +### Packaged artifact verification + +`tools/packaging/verify-electron-package-layout.mjs` verifies packaged worker +artifacts for: + +1. Linux unpacked resources +2. macOS app bundles +3. Windows unpacked app resources + +The script checks: + +1. `epg-parser.worker.js` +2. `database.worker.js` +3. `better-sqlite3` in one approved unpacked node_modules location + +### Development rebuild rule + +Worker-backed database logic is executed from the compiled bundle at: + +1. `dist/apps/electron-backend/workers/database.worker.js` + +Do not assume a source edit is active in the live app. If a fix touches: + +1. `apps/electron-backend/src/app/database/operations/` +2. `apps/electron-backend/src/app/workers/` +3. `apps/electron-backend/src/app/events/database/` +4. preload-backed DB methods consumed by the renderer + +then the safe workflow is: + +1. rebuild the worker bundle, or the full `electron-backend` target if preload, + main-process, or web output also changed +2. confirm the new `dist/` artifact exists or has a fresh timestamp +3. restart the Electron process +4. only then rerun CDP/manual checks or Electron E2E + +A running Electron app keeps using the worker bundle it already loaded at +startup. This is a common reason a worker fix appears "not working" in manual +verification even when the source patch is correct. + +## Testing + +### Unit coverage added + +`apps/electron-backend/src/app/services/database-worker-client.spec.ts` +covers: + +1. worker ready -> request -> response flow +2. event forwarding to a pending request +3. serialized worker error propagation +4. `AbortError` propagation for cancelled work +5. cancel message routing to the live worker +6. worker exit recovery and fresh worker startup + +`apps/electron-backend/src/app/events/epg.events.spec.ts` covers: + +1. shared worker bootstrap usage for the EPG worker +2. `nativeModuleSearchPaths` forwarding into workerData +3. actionable worker-path resolution failures + +`apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts` covers: + +1. packaged and development worker path resolution +2. packaged native-module search path ordering +3. aggregated native module resolution errors + +### Electron responsiveness coverage + +`apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts` covers: + +1. large Xtream import shows the overlay promptly +2. DB worker progress events advance during import +3. renderer animation frames continue while import/delete are in progress +4. large Xtream playlist delete shows row-level busy UI and completes cleanly + +`apps/electron-backend-e2e/src/electron-test-fixtures.ts` now also captures: + +1. `DB_OPERATION_EVENT` history in the renderer +2. a requestAnimationFrame counter for repaint assertions + +For deterministic E2E timing, tests may set: + +```bash +IPTVNATOR_DB_WORKER_BATCH_DELAY_MS=20 +``` + +This delay is test-only and disabled by default. + +### Useful verification commands + +```bash +pnpm exec jest --config apps/electron-backend/jest.config.ts --runInBand apps/electron-backend/src/app/services/database-worker-client.spec.ts apps/electron-backend/src/app/events/epg.events.spec.ts apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts +pnpm nx run electron-backend:build-worker +pnpm exec tsc -p apps/electron-backend/tsconfig.app.json --noEmit +pnpm exec tsc -p apps/web/tsconfig.app.json --noEmit +pnpm nx run electron-backend-e2e:e2e -- --project=electron --grep "Electron Xtream Responsiveness" +pnpm run verify:package-layout -- macos arm64 +pnpm run verify:package-layout -- linux +pnpm run verify:package-layout -- windows +``` + +### Electron runtime validation + +```bash +pnpm nx serve electron-backend +agent-browser --cdp 9222 tab list +agent-browser --cdp 9222 tab 1 +agent-browser --cdp 9222 snapshot -i -c -d 3 +pnpm run smoke:packaged -- macos arm64 +``` + +When worker-backed behavior changed, rebuild and restart before reconnecting: + +```bash +pnpm nx run electron-backend:build-worker +CI=1 NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false pnpm nx run electron-backend:build --skip-nx-cache +stat -f "%Sm %N" dist/apps/electron-backend/workers/database.worker.js +``` + +Then restart the Electron process and reconnect to `127.0.0.1:9222`. + +### Electron freeze tracing + +When a renderer route freezes before DevTools become usable, start Electron with +one of these opt-in trace flags and inspect the terminal output: + +```bash +IPTVNATOR_TRACE_STARTUP=1 pnpm run serve:backend +``` + +Available trace flags: + +1. `IPTVNATOR_TRACE_STARTUP=1` + Enables the broad startup trace set: BrowserWindow lifecycle, renderer + bridge calls, DB worker requests/events, and SQL tracing. +2. `IPTVNATOR_TRACE_IPC=1` + Logs `window.electron.*` method calls crossing the preload bridge so you can + see whether the renderer is still reaching Electron main. +3. `IPTVNATOR_TRACE_DB=1` + Logs `DatabaseWorkerClient` request dispatch, completion timing, and emitted + `DB_OPERATION_EVENT` payloads. +4. `IPTVNATOR_TRACE_SQL=1` + Logs SQLite statements for the shared main-process connection and the DB + worker connection using `better-sqlite3` verbose hooks. +5. `IPTVNATOR_TRACE_WINDOW=1` + Logs BrowserWindow loading, navigation, `unresponsive`, and + `render-process-gone` transitions. +6. `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` + Mirrors renderer console messages into the Electron terminal output when the + renderer itself is the thing getting wedged. + +### Electron E2E troubleshooting + +If a production-mode Electron or Electron E2E launch shows `ERR_FILE_NOT_FOUND` +for hashed `chunk-*.js`, `main-*.js`, or `styles-*.css` assets: + +1. treat `dist/apps/web` as stale first +2. rerun a deterministic production build, for example: + +```bash +CI=1 NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false pnpm nx run electron-backend:build --skip-nx-cache +``` + +3. verify `dist/apps/web/index.html` uses `` before relaunching + Electron in file-backed mode + +## Current Limitations + +These are intentionally still out of scope for this first cut: + +1. request cancellation +2. moving network-heavy Xtream fetches off the current path +3. migrating every remaining small SQLite IPC handler to the worker +4. richer delete progress reporting for bulk destructive operations +5. repo-wide Angular/Jest cleanup for the currently failing web test baseline + +## Extending The Worker + +When adding another heavy SQLite operation: + +1. Put SQL-heavy logic in `apps/electron-backend/src/app/database/operations/`. +2. Add the channel name to `database-worker.types.ts`. +3. Handle it in `database.worker.ts`. +4. Proxy the IPC handler through `DatabaseWorkerClient`. +5. If the renderer needs progress, emit a request-scoped `DbOperationEvent`. +6. Reuse existing preload/service APIs where possible instead of creating a new + renderer-facing contract. +7. Re-run the worker unit test and at least one Electron runtime smoke. diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index bf25532a5..6b64298ee 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -16,7 +16,7 @@ The Stalker ITV live stream layout displays EPG data in the right panel when a l ``` ┌───────────────────────────────────────────────────────────────────────────┐ │ StalkerLiveStreamLayoutComponent │ -│ apps/web/src/app/stalker/stalker-live-stream-layout/ │ +│ libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/ │ │ │ │ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │ │ │ Sidebar │ │ Video Player │ │ EPG Panel │ │ @@ -129,14 +129,14 @@ interface EpgItem { | File | Purpose | |------|---------| | `libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts` | `GetShortEpg`, `GetEpgInfo` enum values | -| `apps/web/src/app/stalker/stalker.store.ts` | `fetchChannelEpg()` method | -| `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | EPG signals + `loadEpgForChannel()` | -| `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | `` integration | +| `libs/portal/stalker/data-access/src/lib/stalker.store.ts` | `fetchChannelEpg()` method | +| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | EPG signals + `loadEpgForChannel()` | +| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | `` integration | | `libs/ui/shared-portals/src/lib/epg-view/epg-view.component.ts` | Shared EPG display component | ### StalkerStore.fetchChannelEpg() -Location: `apps/web/src/app/stalker/stalker.store.ts` (in `withMethods`) +Location: `libs/portal/stalker/data-access/src/lib/stalker.store.ts` (in `withMethods`) ```typescript async fetchChannelEpg(channelId: number | string): Promise diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 748f9eeb6..a5e0ea1eb 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -5,6 +5,8 @@ This document describes the Stalker portal implementation in IPTVnator and where ## Related Docs - [Stalker Portal EPG Architecture](./stalker-epg.md) +- [Portal Detail Navigation](./portal-detail-navigation.md) +- [Embedded Inline Playback](./embedded-inline-playback.md) - [Remote Control Architecture](./remote-control.md) - [Download Manager](./download-manager.md) - [Category Management](./category-management.md) @@ -25,7 +27,7 @@ Stalker support covers: ## Routing Structure -Primary route tree lives in `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker.routes.ts`. +Primary route tree lives in `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`. - `/stalker/:id/vod` - `/stalker/:id/series` @@ -45,23 +47,23 @@ Primary route tree lives in `/Users/4gray/Code/iptvnator/apps/web/src/app/stalke ## Main UI Components -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-main-container.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts` - Category + content layout for `vod` and `series` -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` - ITV live playback, channel navigation, EPG panel integration -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts` +- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts` - Season/episode UI for all Stalker series modes -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts` -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts` -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` ## Store and Data Flow Stalker store is now feature-composed: -- Facade: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker.store.ts` -- Feature slices: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stores/features/*` -- Shared store utils: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stores/utils/*` +- Facade: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker.store.ts` +- Feature slices: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stores/features/*` +- Shared helpers: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/*` Important store responsibilities: @@ -93,8 +95,8 @@ Stalker has multiple real-world data shapes. The current implementation supports Core decision logic and normalization are centralized in: -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-vod.utils.ts` -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/models/*.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/models/*.ts` ## Favorites and Recently Viewed @@ -109,16 +111,22 @@ Current implementation is shared via Stalker-specific helpers: Where this is used: -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts` -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts` -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts` -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` +- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts` + +Navigation rule to preserve: + +- Stalker favorites, recently viewed, and search stay in their current screen and open inline detail state. +- They should not redirect into a canonical content/category/item route because Stalker detail rendering is currently store-state/inline driven, not route driven. +- See [Portal Detail Navigation](./portal-detail-navigation.md). ## Remote Control Integration Stalker live remote control is implemented in: -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` Supported today: @@ -147,7 +155,7 @@ This reduces duplicate UI logic across portal types and keeps compatibility beha Focused regression tests for Stalker VOD mode branching live in: -- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-vod.utils.spec.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts` Covered scenarios include: diff --git a/docs/architecture/stalker-store-api-baseline.md b/docs/architecture/stalker-store-api-baseline.md index 9d8af1868..cc7c641d5 100644 --- a/docs/architecture/stalker-store-api-baseline.md +++ b/docs/architecture/stalker-store-api-baseline.md @@ -1,12 +1,12 @@ # Stalker Store API Baseline -This is the compatibility baseline for refactoring `apps/web/src/app/stalker/stalker.store.ts`. +This is the compatibility baseline for refactoring `libs/portal/stalker/data-access/src/lib/stalker.store.ts`. Goal: keep this public surface stable while splitting to feature stores. ## Source of Truth -- Store implementation: `apps/web/src/app/stalker/stalker.store.ts` +- Store implementation: `libs/portal/stalker/data-access/src/lib/stalker.store.ts` - Baseline created on current branch state before feature-store extraction. ## Public State Signals @@ -108,9 +108,10 @@ Top observed store API usage in app code: Consumer directories sampled: -- `apps/web/src/app/stalker/**` -- `apps/web/src/app/xtream-electron/**` -- `apps/web/src/app/shared/**` +- `libs/portal/stalker/**` +- `libs/portal/xtream/feature/**` +- `libs/portal/catalog/feature/**` +- `libs/ui/components/**` ## Invariants to Preserve During Refactor @@ -123,4 +124,3 @@ Consumer directories sampled: - Full-portal auth path continues through `StalkerSessionService`. - Non-auth/simple path continues through `DataService.sendIpcEvent(STALKER_REQUEST, ...)`. - Resource-driven loading signals preserve existing names. - diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 7dd98c6dc..0e8fd33e7 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -1,188 +1,136 @@ -# Workspace Dashboard Plan +# Workspace Dashboard -## Context +This document records the current dashboard implementation inside the workspace +shell. It replaces the earlier dashboard plan document. -The workspace shell is now the primary app entrypoint. The dashboard should evolve from a placeholder into an operational home page that helps users: +Related: -1. Quickly continue playback/work. -2. Switch context across M3U, Xtream, and Stalker sources. -3. Monitor relevant content (recent items, EPG, status) in one place. +- [Workspace Shell](./workspace-shell.md) -This document defines the implementation plan before Phase 1 work starts. +## Summary -## Status Snapshot (February 22, 2026) +- The dashboard is the default `/workspace` landing page. +- It is a widget-based surface with persisted layout and widget settings. +- The current implementation favors a constrained, stable layout over a full + freeform grid system. -### Delivered +Core implementation: -1. Dashboard route is active in workspace shell with persisted widget layout. -2. Widget host and configurable widget model are implemented. -3. `Customize` mode supports: - 1. Enable/disable widgets - 2. Reorder widgets (up/down) - 3. Widget scope (provider + playlist selection) -4. Active widgets in production: - 1. Continue Watching - 2. Recent Sources - 3. Source Statistics - 4. Recently Watched (global) - 5. Global Favorites -5. Recently Watched and Global Favorites support: - 1. Content-kind chips (channels/vod/series) - 2. List/grid toggle - 3. Direct deep linking from widget item to target view/detail -6. Xtream live deep links now auto-start playback when opened from dashboard widgets. -7. Dashboard now uses a shared Material-based widget shell for consistent visual structure. -8. Customize mode now supports drag-and-drop ordering and widget size presets (`1/3`, `1/2`, `2/3`, `full`). +1. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts` +2. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html` +3. `libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts` +4. `libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts` +5. `libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts` +6. `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts` -### Partially Delivered / Deviation From Initial Phase 1 List +## Current Widget Set -1. EPG Radar was intentionally removed from the current dashboard scope and is postponed. -2. Recent Activity / Recently Added widget was intentionally removed from current scope. +Registered widget types: -### Not Started +1. `source-stats` +2. `continue-watching` +3. `recently-watched` +4. `global-favorites` -1. Phase 4 external widgets (RSS/scores/news adapters). +Default layout: -### Immediate Next Widget Tasks (Recommended) +1. `continue-watching` + 1. Enabled by default + 2. Size `full` +2. `recently-watched` + 1. Enabled by default + 2. Size `two-thirds` + 3. Scope-aware +3. `global-favorites` + 1. Enabled by default + 2. Size `one-third` + 3. Scope-aware +4. `source-stats` + 1. Present in the registry + 2. Disabled by default + 3. Size `one-third` -1. Expand widget settings UX (scope presets, bulk provider toggles). +The old refactor summary mentioned `Recent Sources`, but that widget is not in +the current registry and should not be documented as shipped behavior. -## Product Direction +## Layout And Persistence Contract -The dashboard should be a configurable widget system, but introduced in stages: +The dashboard persists a versioned layout object in local storage. -1. Start with useful, stable widgets and a constrained layout. -2. Add edit/customization workflows after widget value is proven. -3. Add advanced drag/resize and external integrations later. +Current storage details: -This avoids building heavy layout mechanics before core data widgets are solid. +1. Storage key: `workspace-dashboard-layout-v3` +2. Schema version: `12` +3. Size presets: + 1. `one-third` + 2. `half` + 3. `two-thirds` + 4. `full` +4. Scope settings: + 1. `providers: Array<'m3u' | 'xtream' | 'stalker'>` + 2. `playlistIds: string[]` -## UX Direction +Normalization rules in `DashboardLayoutService`: -Design style: professional operator console (dense, calm, high-signal). +1. Stored widgets are merged against `DEFAULT_DASHBOARD_WIDGETS`. +2. Missing widgets are restored from defaults. +3. Removed or invalid settings fall back to normalized defaults. +4. Titles and descriptions come from the current code-defined defaults, not + stale stored values. +5. Widget order is reindexed after normalization. -Target layout: +## Rendering Flow -1. Top row: continue actions, recent sources, health/status. -2. Middle row: discovery widgets (recently viewed, recently added, favorites). -3. Edit mode: add/remove/reorder widgets and configure source scope. +1. `WorkspaceDashboardComponent` reads `DashboardLayoutService.state()`. +2. Enabled widgets are filtered and rendered in layout order. +3. `DashboardWidgetHostComponent` maps `widget.type` to a concrete widget + component. +4. Widgets receive the normalized config, including size and optional scope. +5. Data comes from `DashboardDataService` and existing provider/state services. +6. Widget actions deep-link back into workspace/provider routes. -## Architecture +## Customize Mode -## Core Components +Customize mode is part of the current product, not future work. -1. `DashboardPageComponent` (container/layout/edit mode orchestration) -2. `DashboardWidgetHostComponent` (widget factory/renderer by type) -3. `DashboardLayoutStore` (signal-based state for layout, settings, edit mode) -4. `DashboardPersistenceService` (save/load layout, version migration) -5. `DashboardDataFacade` (aggregate provider data for widgets) +Supported actions: -## Widget Contract +1. Toggle widget visibility. +2. Drag-and-drop reorder for visible widgets. +3. Change widget size within the fixed preset list. +4. Configure provider scope for scoped widgets. +5. Configure playlist scope for scoped widgets. +6. Reset the layout to defaults. -```ts -type WidgetSize = 'one-third' | 'half' | 'two-thirds' | 'full'; +Scope-aware widgets currently rely on a provider/playlist filter model rather +than per-widget custom query systems. -interface WidgetScope { - providers: Array<'m3u' | 'xtream' | 'stalker'>; - playlistIds?: string[]; -} +## UX Rules -interface DashboardWidget { - id: string; - type: string; - title: string; - size: WidgetSize; - order: number; - enabled: boolean; - scope: WidgetScope; - settings: Record; -} +1. Widgets should represent user tasks, not provider internals. +2. Each widget must own its loading, empty, and error states. +3. Dashboard failures must stay isolated to the widget that failed. +4. Widget navigation should resolve directly into the relevant content context. +5. New widgets should fit the existing constrained layout model unless the + dashboard architecture is explicitly being expanded. -interface DashboardLayout { - version: number; - widgets: DashboardWidget[]; -} -``` +## Adding Or Changing Widgets -## Capability Rules +Current workflow: -Widgets must degrade gracefully per provider: +1. Add or update the widget type in `dashboard-widget.model.ts`. +2. Implement the widget UI under `libs/workspace/dashboard/ui/src/lib/widgets/`. +3. Register the widget in `dashboard-widget-host.component.ts`. +4. Add default state and migration-safe behavior in + `dashboard-layout.service.ts`. +5. Extend `dashboard-data.service.ts` or reuse an existing feature service. +6. Ensure the widget has explicit empty/error/loading states and valid + workspace deep links. -1. If a source/provider does not support required data (for example EPG), show a clear empty/unsupported state. -2. Widgets never hard fail the page; each widget owns loading/error states. -3. Scope defaults to "all supported providers" unless user config overrides it. +## Deferred Work -## Phased Delivery +These items are intentionally not part of the current contract: -## Phase 1 (Now): Production Dashboard V1 - -Scope: - -1. Replace placeholder dashboard with real widget host + predefined layout. -2. Fixed grid slots (no free drag/resize yet). -3. Initial widgets: - 1. Recent Sources - 2. Continue Watching - 3. Source Statistics - 4. Recently Added / Recently Viewed (provider-aware) -4. Persist enabled/disabled and order (simple list reorder if needed). - -Out of scope: - -1. Freeform drag-and-drop grid resizing. -2. External data providers (RSS/sports/news). -3. Full widget marketplace. - -Acceptance criteria: - -1. Dashboard loads with useful content for at least one active source type. -2. Empty states are clear and actionable (links to Sources/settings). -3. No route regressions for existing workspace sections. -4. Layout/settings survive app restart. - -## Phase 2: Edit Mode and Configuration - -Scope: - -1. "Customize dashboard" mode. -2. Enable/disable widgets. -3. Widget-level source scoping (providers + selected playlists). -4. Order management (move up/down or drag reorder within constrained grid). - -## Phase 3: Advanced Layout (Drag/Resize) - -Scope: - -1. True grid layout manager with size presets and drag repositioning. -2. Collision handling and responsive breakpoint behavior. -3. Optional "reset layout" and preset templates. - -## Phase 4: External Widgets - -Scope: - -1. Adapter interface for non-playlist data widgets (RSS, scores, news). -2. Polling/cache strategy with rate limits. -3. User opt-in and failure isolation per external source. - -## Data and Performance Notes - -1. Use memoized/computed selectors for widget inputs. -2. Avoid redundant provider fetches; reuse existing stores/services where possible. -3. Update widgets incrementally and isolate heavy computations in facade/store utilities. - -## File/Module Placement (Proposed) - -1. `libs/workspace/dashboard/feature/` (page + edit mode orchestration) -2. `libs/workspace/dashboard/ui/` (widget host + widget components) -3. `libs/workspace/dashboard/data-access/` (state model + persistence + data facade/service) -4. Route integration remains in `apps/web/src/app/app.routes.ts` and workspace shell. - -## Open Questions - -1. Should dashboard layout be global per app profile, or per active workspace/provider mix? -2. Which widgets are enabled by default for new users vs migrated users? - -## Next Step - -Start Phase 1 implementation using this document as the source of truth and track deviations explicitly in follow-up updates. +1. Freeform drag/resize grid with collision management. +2. External data widgets such as RSS, sports, or news adapters. +3. A widget marketplace or plugin system. diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md new file mode 100644 index 000000000..c0a92f304 --- /dev/null +++ b/docs/architecture/workspace-shell.md @@ -0,0 +1,128 @@ +# Workspace Shell + +This document records the current workspace-first shell contract. It is the +stable replacement for the older UI refactor summary. + +Related: + +- [Workspace Dashboard](./workspace-dashboard.md) + +## Summary + +- `/workspace` is the primary app surface. +- `WorkspaceShellComponent` owns the persistent frame: rail, header, optional + context panel, content outlet, and external playback footer. +- Descendant workspace pages inherit `layout = 'workspace'` from the + `/workspace` root route. +- Provider route trees now bootstrap through route-scoped session providers + instead of nested provider shell components. + +Core implementation: + +1. `apps/web/src/app/app.routes.ts` +2. `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` +3. `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html` +4. `libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts` +5. `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` +6. `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts` + +## Route Contract + +Current workspace routes: + +1. `/` -> `/workspace` +2. `/workspace` -> `/workspace/dashboard` +3. `/workspace/dashboard` +4. `/workspace/sources` +5. `/workspace/playlists/:id/:view` +6. `/workspace/global-favorites` +7. `/workspace/downloads` +8. `/workspace/settings` +9. `/workspace/xtreams/:id/...` +10. `/workspace/stalker/:id/...` + +Compatibility redirect: + +1. `/settings` -> `/workspace/settings` + +Provider route integration: + +1. `apps/web/src/app/app.routes.ts` marks the `/workspace` root route with + `data.layout = 'workspace'`. +2. `isWorkspaceLayoutRoute(...)` treats that layout marker as inherited route + state for all descendants. +3. Xtream and Stalker parent routes attach route-scoped session providers that + bootstrap the active playlist, sync provider section state, and clean up + provider-local state when the route is destroyed. +4. Workspace routes no longer rely on nested provider shell components for + hidden local chrome. + +## Shell Structure + +The shell is intentionally split into four persistent regions: + +1. Left rail: + 1. Static workspace links for dashboard and sources. + 2. Provider-aware context links derived from the active or current playlist. +2. Top header: + 1. Playlist switcher. + 2. Route-aware search input and command palette trigger. + 3. Add source action. + 4. Global favorites shortcut. + 5. Downloads shortcut in Electron. + 6. Context actions menu for playlist/account or section-level actions. +3. Main body: + 1. Optional left context panel. + 2. Main router outlet content. +4. Optional footer: + 1. External playback session bar when a docked session is visible. + +## Context Panel Rules + +The shell decides which secondary panel to show from the current route: + +1. `/workspace/sources` + 1. `WorkspaceSourcesFiltersPanelComponent` +2. Xtream category sections (`live`, `vod`, `series`) + 1. `WorkspaceContextPanelComponent` +3. Stalker category sections (`itv`, `vod`, `series`) + 1. `WorkspaceContextPanelComponent` +4. `/workspace/settings` + 1. `WorkspaceSettingsContextPanelComponent` +5. Downloads sections + 1. `WorkspaceCollectionContextPanelComponent` + +The context panel is part of the shell contract. New workspace-level routes +should explicitly decide whether they need one rather than adding local +sidebars inside feature pages. + +## Search And Navigation Rules + +Search is shell-owned and route-aware: + +1. Disabled on settings routes. +2. Enabled on sources routes. +3. Enabled for supported Xtream and Stalker content/search views. +4. Placeholder text and search handling vary by provider and section. +5. Input changes are debounced before route/store updates are applied. + +Rail navigation is also shell-owned: + +1. Workspace-global entries are static. +2. Provider entries come from `buildPortalRailLinks(...)`. +3. On dashboard, sources, settings, and global favorites, the shell falls back + to the currently selected playlist so provider navigation remains available + even outside a provider route. + +## Maintenance Guidance + +Use this document as the source of truth when changing workspace shell behavior. + +1. New top-level user destinations should default to child routes under + `/workspace`. +2. Shared provider navigation logic belongs in portal-shared util/UI libraries, + not duplicated inside the shell. +3. If a provider route changes how playlist/session bootstrap works, update the + route-session provider and shell-facing route contract together. +4. Historical migration notes, cleanup lists, and one-off refactor steps should + stay out of this file; track them in issues or PR notes instead. diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 19bade2ff..003e46ee4 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -25,6 +25,7 @@ faker.seed(seed) ←── all faker calls use same seed per credentia generateCategories() ←── live / vod / series categories │ generateLiveStreams() ←── live TV stream list + scenario EPG fixture? ←── optional deterministic per-stream EPG override generateVodStreams() ←── VOD movie list generateSeriesItems() ←── series list generateSeriesInfo() ←── nested seasons + episodes (pre-populated) @@ -57,6 +58,7 @@ apps/xtream-mock-server/ ├── handlers/ │ ├── get-account-info.handler.ts │ ├── get-categories.handler.ts ← live/vod/series categories + │ ├── get-full-epg.handler.ts ← full EPG + legacy typo alias │ ├── get-streams.handler.ts ← live/vod/series stream lists │ ├── get-vod-info.handler.ts │ ├── get-series-info.handler.ts @@ -159,6 +161,31 @@ All redirect to a publicly available HLS test stream Note: `title` and `description` are **base64-encoded**, matching the real Xtream API. +### `get_simple_data_table` / `get_simple_date_table` + +Both actions return the same full per-channel schedule shape: + +```json +{ + "epg_listings": [ + { + "id": "10000-current", + "epg_id": "channel-10000.mock", + "title": "base64encodedTitle", + "description": "base64encodedDescription", + "start": "2026-04-05 04:30:00", + "end": "2026-04-05 05:00:00", + "start_timestamp": "1775363400", + "stop_timestamp": "1775365200", + "channel_id": "channel-10000.mock" + } + ] +} +``` + +The legacy `get_simple_date_table` typo alias exists because real Xtream panels +sometimes only respond to that misspelled action. + ### `get_series_info` (structure) ```json @@ -193,10 +220,24 @@ Note: `title` and `description` are **base64-encoded**, matching the real Xtream | `large:large` | 9999 | 20 each | 200 | active | | `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | | `minimal:minimal` | 3003 | 2 each | 5 | active | +| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active | | `expired:expired` | 4004 | 4 each | 10 | Expired | | `inactive:inactive` | 5005 | 4 each | 10 | Disabled | | `` | hash | 6 each | 30 | active | +### `epg:epg` fixture details + +This scenario is reserved for Xtream EPG tests: + +- live category `EPG Focus` contains deterministic channels such as `Timezone News` +- `Timezone News` serves a fixed `get_short_epg` window and a full `get_simple_data_table` schedule +- the full schedule includes a program that spans a UTC midnight boundary and another program after midnight +- raw `start` / `end` strings are intentionally offset from `start_timestamp` / `stop_timestamp` + +That deliberate mismatch lets Electron tests verify the renderer uses timestamp +fields for sorting, current-program selection, progress bars, and local clock +labels instead of trusting provider-local strings. + --- ## Playwright Integration @@ -233,5 +274,6 @@ await page.route('**/localhost:3000/xtream**', async (route) => { - **Add new actions**: Implement a handler function and add a `case` in `routes/dispatch.ts` - **Add new scenarios**: Add an entry to `SCENARIOS` in `scenarios.ts` +- **Add deterministic EPG fixtures**: Extend `ScenarioConfig.epgFixture` and populate `epgListingsByStreamId` in `data-store.ts` - **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario - **Custom stream URLs**: Edit the HLS stub redirect in `main.ts` diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index d2d2debca..076018e6a 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -42,7 +42,7 @@ /> } @case ('url') { - + } @case ('text') { @@ -63,7 +63,7 @@ mat-button type="button" [disabled]="!urlUpload()?.form?.valid" - (click)="urlUpload()?.urlAdded?.emit(urlUpload()?.form?.value?.playlistUrl)" + (click)="submitUrlPlaylist()" > {{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }} diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts new file mode 100644 index 000000000..80e097fcc --- /dev/null +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts @@ -0,0 +1,105 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { DataService } from 'services'; +import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces'; +import { AddPlaylistDialogComponent } from './add-playlist-dialog.component'; + +describe('AddPlaylistDialogComponent', () => { + let component: AddPlaylistDialogComponent; + let dataService: { sendIpcEvent: jest.Mock }; + let dialogRef: { close: jest.Mock }; + + beforeEach(() => { + dataService = { + sendIpcEvent: jest.fn(), + }; + dialogRef = { + close: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + { + provide: DataService, + useValue: dataService, + }, + { + provide: MatDialogRef, + useValue: dialogRef, + }, + { + provide: Store, + useValue: { + dispatch: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((value: string) => value), + }, + }, + { + provide: MAT_DIALOG_DATA, + useValue: null, + }, + ], + }); + + component = TestBed.runInInjectionContext( + () => new AddPlaylistDialogComponent() + ); + }); + + it('sends a trimmed custom title for URL playlists', () => { + (component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({ + form: { + getRawValue: () => ({ + playlistName: ' My Playlist ', + playlistUrl: ' https://example.com/list.m3u ', + }), + }, + })); + + component.submitUrlPlaylist(); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + PLAYLIST_PARSE_BY_URL, + { + title: 'My Playlist', + url: 'https://example.com/list.m3u', + } + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('omits the title when the optional name is blank', () => { + (component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({ + form: { + getRawValue: () => ({ + playlistName: ' ', + playlistUrl: 'https://example.com/list.m3u', + }), + }, + })); + + component.submitUrlPlaylist(); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + PLAYLIST_PARSE_BY_URL, + { + url: 'https://example.com/list.m3u', + } + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); +}); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index adeac8169..df7fcce50 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -15,7 +15,6 @@ import { PlaylistCategory, } from '@iptvnator/workspace/shell/util'; import { PlaylistActions } from 'm3u-state'; -import { getFilenameFromUrl } from 'm3u-utils'; import { DataService } from 'services'; import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces'; import { FileUploadComponent } from '../file-upload/file-upload.component'; @@ -110,13 +109,22 @@ export class AddPlaylistDialogComponent { } /** - * Sends url of the playlist to the renderer process - * @param playlistUrl url of the added playlist + * Sends url of the playlist to the renderer process and preserves the + * existing fallback title behavior when the optional name is blank. */ - sendPlaylistsUrl(playlistUrl: string): void { + submitUrlPlaylist(): void { + const formValue = this.urlUpload()?.form?.getRawValue(); + const playlistUrl = formValue?.playlistUrl?.trim(); + + if (!playlistUrl) { + return; + } + + const playlistName = this.normalizeOptionalValue(formValue?.playlistName); + this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { - title: getFilenameFromUrl(playlistUrl), url: playlistUrl, + ...(playlistName ? { title: playlistName } : {}), }); this.closeDialog(); } @@ -139,4 +147,9 @@ export class AddPlaylistDialogComponent { closeDialog(): void { this.dialogRef.close(); } + + private normalizeOptionalValue(value?: string | null): string | undefined { + const normalizedValue = value?.trim(); + return normalizedValue ? normalizedValue : undefined; + } } diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html index e03bf5c10..64497b2aa 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html @@ -7,6 +7,14 @@ formControlName="playlistUrl" /> + + + @if (!isDesktop) {

{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }} diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts index c01df36e1..f810f3b72 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts @@ -1,8 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatCardModule } from '@angular/material/card'; -import { MatInputModule } from '@angular/material/input'; -import { By } from '@angular/platform-browser'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; import { MockModule } from 'ng-mocks'; @@ -16,9 +14,8 @@ describe('UrlUploadComponent', () => { TestBed.configureTestingModule({ imports: [ UrlUploadComponent, - MockModule(MatInputModule), - MockModule(MatCardModule), MockModule(FormsModule), + MockModule(MatFormFieldModule), MockModule(ReactiveFormsModule), TranslateModule.forRoot(), NoopAnimationsModule, @@ -36,27 +33,28 @@ describe('UrlUploadComponent', () => { expect(component).toBeTruthy(); }); - it('submit form with playlist url', waitForAsync(() => { - jest.spyOn(component.urlAdded, 'emit'); - const TEST_URL = 'http://example.org/playlist.m3u'; - const submitButton = - fixture.debugElement.nativeElement.querySelector('button'); + it('accepts an optional playlist name without affecting url validation', () => { + const testUrl = 'http://example.org/playlist.m3u'; - // test input field validation - expect(submitButton.disabled).toBeTruthy(); - component.form.setValue({ playlistUrl: 'wrong url here' }); + component.form.setValue({ + playlistName: ' Custom Playlist ', + playlistUrl: 'wrong url here', + }); fixture.detectChanges(); - expect(submitButton.disabled).toBeTruthy(); - component.form.setValue({ playlistUrl: TEST_URL + '8' }); - fixture.detectChanges(); - expect(submitButton.disabled).toBeFalsy(); - component.form.setValue({ playlistUrl: TEST_URL }); - fixture.detectChanges(); - expect(submitButton.disabled).toBeFalsy(); + expect(component.form.valid).toBeFalsy(); - const form = fixture.debugElement.query(By.css('form')); - form.triggerEventHandler('ngSubmit', null); + component.form.setValue({ + playlistName: '', + playlistUrl: testUrl, + }); + fixture.detectChanges(); + expect(component.form.valid).toBeTruthy(); - expect(component.urlAdded.emit).toHaveBeenCalledWith(TEST_URL); - })); + component.form.setValue({ + playlistName: ' ', + playlistUrl: testUrl, + }); + fixture.detectChanges(); + expect(component.form.valid).toBeTruthy(); + }); }); diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index 752327d99..a6c1f1690 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -1,34 +1,42 @@ -import { Component, EventEmitter, inject, OnInit, Output } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { FormBuilder, + FormControl, FormGroup, ReactiveFormsModule, Validators, } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; @Component({ selector: 'app-url-upload', templateUrl: './url-upload.component.html', - imports: [MatInputModule, ReactiveFormsModule, TranslatePipe], + imports: [ + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + TranslatePipe, + ], }) export class UrlUploadComponent implements OnInit { private readonly fb = inject(FormBuilder); - /** Emits url string to the parent component on form submit */ - @Output() urlAdded: EventEmitter = new EventEmitter(); - - form: FormGroup; + form!: FormGroup<{ + playlistName: FormControl; + playlistUrl: FormControl; + }>; readonly isDesktop = !!window.electron; - ngOnInit() { + ngOnInit(): void { const urlRegex = '(https?://.*?)'; - this.form = this.fb.group({ + this.form = this.fb.nonNullable.group({ playlistUrl: [ '', [Validators.required, Validators.pattern(urlRegex)], ], + playlistName: [''], }); } } diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts index a5374e029..b3375312f 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts @@ -1,9 +1,12 @@ import { + inject, ChangeDetectionStrategy, Component, input, } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; +import { routeParamSignal } from '@iptvnator/portal/shared/util'; @Component({ selector: 'app-m3u-collection-route', @@ -12,11 +15,19 @@ import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class M3uCollectionRouteComponent { + private readonly route = inject(ActivatedRoute); + readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input('m3u'); + readonly playlistId = routeParamSignal( + this.route, + 'id', + (value) => value ?? undefined + ); } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 189343fc2..83b04d635 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -1,16 +1,22 @@

} } @else { -
- live_tv -

- {{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }} -

-
+ } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss index 307fedf4c..924b1303d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss @@ -82,32 +82,6 @@ } } -.empty-state-container { - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - color: rgba(15, 23, 42, 0.45); - - .empty-state-icon { - width: 72px; - height: 72px; - margin-bottom: 24px; - font-size: 72px; - opacity: 0.25; - } - - .empty-state-title { - margin: 0; - font-family: 'DM Sans', sans-serif; - font-size: 1.25rem; - font-weight: 500; - opacity: 0.7; - } -} - @media only screen and (max-width: 599px) { :host { flex-direction: column-reverse; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 837fed7ee..1f76c6f2d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -140,6 +140,7 @@ describe('VideoPlayerComponent', () => { beforeEach(async () => { syncStoreState(null); + localStorage.removeItem('m3u-sidebar-width'); player.set(VideoPlayer.VideoJs); showCaptions.set(false); currentEpgProgram.set(null); @@ -285,11 +286,74 @@ describe('VideoPlayerComponent', () => { ).not.toBeNull(); }); - it('renders the empty state when no channel is active', () => { + it('updates the outer sidebar width while grouped view requests a larger total width', () => { + fixture.detectChanges(); + + component.onGroupedSidebarWidthRequested(540); + fixture.detectChanges(); + + const sidebar = fixture.nativeElement.querySelector( + '.sidebar' + ) as HTMLElement | null; + + expect(sidebar?.style.width).toBe('540px'); + }); + + it('uses the single-pane sidebar key for all-channel view instead of the groups total key', () => { + fixture.destroy(); + + localStorage.setItem('m3u-sidebar-width', '320'); + localStorage.setItem('m3u-groups-sidebar-width', '560'); + + fixture = TestBed.createComponent(VideoPlayerComponent); + component = fixture.componentInstance; + headerContext = TestBed.inject(WorkspaceHeaderContextService); + + fixture.detectChanges(); + + const sidebar = fixture.nativeElement.querySelector( + '.sidebar' + ) as HTMLElement | null; + + expect(sidebar?.style.width).toBe('320px'); + }); + + it('tracks manual sidebar resize and persists the committed width', () => { + fixture.detectChanges(); + + component.onSidebarWidthChange(420); + fixture.detectChanges(); + + const sidebar = fixture.nativeElement.querySelector( + '.sidebar' + ) as HTMLElement | null; + + expect(sidebar?.style.width).toBe('420px'); + + component.onSidebarResizeEnd(420); + + expect(localStorage.getItem('m3u-sidebar-width')).toBe('420'); + }); + + it('clamps and persists grouped-view sidebar width requests on resize end', () => { + fixture.detectChanges(); + + component.onGroupedSidebarWidthRequestEnded(640); + fixture.detectChanges(); + + const sidebar = fixture.nativeElement.querySelector( + '.sidebar' + ) as HTMLElement | null; + + expect(sidebar?.style.width).toBe('600px'); + expect(localStorage.getItem('m3u-sidebar-width')).toBe('600'); + }); + + it('renders the shared empty state when no channel is active', () => { fixture.detectChanges(); const emptyState = fixture.nativeElement.querySelector( - '.empty-state-container' + 'app-portal-empty-state' ) as HTMLElement | null; expect(emptyState).not.toBeNull(); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 88581d0a9..9bf871527 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -7,11 +7,12 @@ import { Injector, OnDestroy, OnInit, + computed, effect, inject, + signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { MatIcon } from '@angular/material/icon'; import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; @@ -50,6 +51,7 @@ import { PORTAL_EXTERNAL_PLAYBACK, WorkspaceHeaderContextService, } from '@iptvnator/portal/shared/util'; +import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; import { ArtPlayerComponent, AudioPlayerComponent, @@ -71,6 +73,11 @@ import { } from 'shared-interfaces'; const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg'; +const M3U_SIDEBAR_STORAGE_KEY = 'm3u-sidebar-width'; +const M3U_GROUPS_SIDEBAR_STORAGE_KEY = 'm3u-groups-sidebar-width'; +const M3U_SIDEBAR_MIN_WIDTH = 200; +const M3U_SIDEBAR_MAX_WIDTH = 600; +const M3U_SIDEBAR_DEFAULT_WIDTH = 460; @Component({ selector: 'app-video-player', @@ -81,7 +88,7 @@ const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg'; CommonModule, EpgListComponent, HtmlVideoPlayerComponent, - MatIcon, + PortalEmptyStateComponent, ResizableDirective, SidebarComponent, TranslatePipe, @@ -109,6 +116,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly activeChannel = this.store.selectSignal(selectActive); readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; readonly channels = this.store.selectSignal(selectChannels); + readonly sidebarStorageKey = computed(() => + this.activeView() === 'groups' + ? M3U_GROUPS_SIDEBAR_STORAGE_KEY + : M3U_SIDEBAR_STORAGE_KEY + ); + readonly sidebarWidth = signal(M3U_SIDEBAR_DEFAULT_WIDTH); + readonly sidebarMinWidth = M3U_SIDEBAR_MIN_WIDTH; + readonly sidebarMaxWidth = M3U_SIDEBAR_MAX_WIDTH; /** Channels list */ readonly channels$: Observable = this.store.select( @@ -168,6 +183,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { }; }); + effect(() => { + this.sidebarWidth.set( + this.loadSidebarWidth(this.sidebarStorageKey()) + ); + }); + effect(() => { const playlistId = this.activePlaylistId(); const activeChannel = this.activeChannel(); @@ -361,6 +382,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.statusSubscription?.unsubscribe(); } + onSidebarWidthChange(width: number): void { + this.sidebarWidth.set(this.clampSidebarWidth(width)); + } + + onSidebarResizeEnd(width: number): void { + this.persistSidebarWidth(this.sidebarStorageKey(), width); + } + + onGroupedSidebarWidthRequested(width: number): void { + this.sidebarWidth.set(this.clampSidebarWidth(width)); + } + + onGroupedSidebarWidthRequestEnded(width: number): void { + this.persistSidebarWidth(this.sidebarStorageKey(), width); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... @@ -393,6 +430,38 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { }); } + private loadSidebarWidth(storageKey: string): number { + const fallbackKey = + storageKey === M3U_GROUPS_SIDEBAR_STORAGE_KEY + ? M3U_SIDEBAR_STORAGE_KEY + : ''; + const storedWidth = Number.parseInt( + localStorage.getItem(storageKey) ?? + (fallbackKey ? localStorage.getItem(fallbackKey) : '') ?? + '', + 10 + ); + + return this.clampSidebarWidth( + Number.isNaN(storedWidth) + ? M3U_SIDEBAR_DEFAULT_WIDTH + : storedWidth + ); + } + + private persistSidebarWidth(storageKey: string, width: number): void { + const clampedWidth = this.clampSidebarWidth(width); + this.sidebarWidth.set(clampedWidth); + localStorage.setItem(storageKey, clampedWidth.toString()); + } + + private clampSidebarWidth(width: number): number { + return Math.max( + M3U_SIDEBAR_MIN_WIDTH, + Math.min(M3U_SIDEBAR_MAX_WIDTH, width) + ); + } + private async persistRecentlyViewedChannel( playlistId: string, channel: Channel diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts new file mode 100644 index 000000000..4c598fec1 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -0,0 +1,268 @@ +import { signal } from '@angular/core'; +import { + ComponentFixture, + TestBed, + fakeAsync, + flushMicrotasks, + tick, +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { PortalStatusService } from 'services'; +import { PlaylistMeta } from 'shared-interfaces'; +import { PlaylistSwitcherComponent } from './playlist-switcher.component'; + +const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query'; +const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters'; + +function createPlaylist( + overrides: Partial & { _id: string } +): PlaylistMeta { + return { + _id: overrides._id, + title: overrides.title ?? overrides.filename ?? overrides._id, + filename: overrides.filename, + count: overrides.count ?? 0, + importDate: + overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(), + autoRefresh: overrides.autoRefresh ?? false, + ...overrides, + } as PlaylistMeta; +} + +describe('PlaylistSwitcherComponent', () => { + let fixture: ComponentFixture; + let component: PlaylistSwitcherComponent; + let playlistsSignal: ReturnType>; + let resolvedPlaylistIdSignal: ReturnType>; + let activePlaylistSignal: ReturnType>; + let playlistContext: { + playlists: typeof playlistsSignal; + allPlaylistsLoaded: ReturnType>; + resolvedPlaylistId: typeof resolvedPlaylistIdSignal; + activePlaylist: typeof activePlaylistSignal; + selectPlaylist: jest.Mock; + }; + let portalStatusService: { + checkPortalStatus: jest.Mock; + getStatusClass: jest.Mock; + }; + + const m3uPlaylist = createPlaylist({ + _id: 'm3u-1', + count: 24, + importDate: '2026-04-05T09:00:00.000Z', + title: 'Morning M3U', + url: 'http://example.test/m3u.m3u', + }); + const stalkerPlaylist = createPlaylist({ + _id: 'stalker-1', + importDate: '2026-04-05T11:00:00.000Z', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://127.0.0.1:3210/portal.php', + title: 'Living Room Stalker', + }); + const xtreamPlaylist = createPlaylist({ + _id: 'xtream-1', + importDate: '2026-04-05T12:00:00.000Z', + password: 'pass1', + serverUrl: 'http://127.0.0.1:3211', + title: 'Cinema Xtream', + updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(), + username: 'user1', + }); + + async function createComponent(): Promise { + await TestBed.configureTestingModule({ + imports: [ + PlaylistSwitcherComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: PlaylistContextFacade, + useValue: playlistContext, + }, + { + provide: PortalStatusService, + useValue: portalStatusService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PlaylistSwitcherComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('currentTitle', ''); + fixture.componentRef.setInput('subtitle', '3 playlists'); + fixture.detectChanges(); + } + + beforeEach(() => { + localStorage.clear(); + + playlistsSignal = signal([m3uPlaylist, stalkerPlaylist, xtreamPlaylist]); + resolvedPlaylistIdSignal = signal(xtreamPlaylist._id); + activePlaylistSignal = signal(xtreamPlaylist); + + playlistContext = { + playlists: playlistsSignal, + allPlaylistsLoaded: signal(true), + resolvedPlaylistId: resolvedPlaylistIdSignal, + activePlaylist: activePlaylistSignal, + selectPlaylist: jest.fn((playlist: PlaylistMeta) => { + resolvedPlaylistIdSignal.set(playlist._id); + activePlaylistSignal.set(playlist); + }), + }; + portalStatusService = { + checkPortalStatus: jest + .fn() + .mockResolvedValueOnce('active') + .mockResolvedValueOnce('inactive'), + getStatusClass: jest.fn((status: string) => `status-${status}`), + }; + }); + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it('reads persisted search and type filters on startup and filters playlists accordingly', async () => { + localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room'); + localStorage.setItem( + PLAYLIST_TYPE_FILTER_STORAGE_KEY, + JSON.stringify({ + m3u: false, + stalker: true, + xtream: true, + }) + ); + + await createComponent(); + + expect(component.searchQuery()).toBe('room'); + expect(component.isTypeFilterSelected('m3u')).toBe(false); + expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]); + }); + + it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => { + await createComponent(); + + expect(component.filteredPlaylists().map((playlist) => playlist._id)).toEqual([ + xtreamPlaylist._id, + stalkerPlaylist._id, + m3uPlaylist._id, + ]); + + component.togglePlaylistTypeFilter('m3u'); + component.togglePlaylistTypeFilter('stalker'); + expect(component.playlistTypeFilters()).toEqual({ + m3u: false, + stalker: false, + xtream: true, + }); + + component.togglePlaylistTypeFilter('xtream'); + expect(component.playlistTypeFilters()).toEqual({ + m3u: false, + stalker: false, + xtream: true, + }); + }); + + it('selects a playlist, closes the menu, and emits the selected id', async () => { + await createComponent(); + + const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu'); + const selectedIds: string[] = []; + component.playlistSelected.subscribe((playlistId) => { + selectedIds.push(playlistId); + }); + + component.selectPlaylist(stalkerPlaylist); + fixture.detectChanges(); + + expect(closeMenuSpy).toHaveBeenCalledTimes(1); + expect(playlistContext.selectPlaylist).toHaveBeenCalledWith( + stalkerPlaylist + ); + expect(selectedIds).toEqual([stalkerPlaylist._id]); + expect(component.isSelected(stalkerPlaylist)).toBe(true); + }); + + it('emits playlist and account info requests through the context actions', async () => { + await createComponent(); + + const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu'); + const playlistInfoSpy = jest.fn(); + const accountInfoSpy = jest.fn(); + component.playlistInfoRequested.subscribe(playlistInfoSpy); + component.accountInfoRequested.subscribe(accountInfoSpy); + + component.requestPlaylistInfo(); + component.requestAccountInfo(); + + expect(closeMenuSpy).toHaveBeenCalledTimes(2); + expect(playlistInfoSpy).toHaveBeenCalledTimes(1); + expect(accountInfoSpy).toHaveBeenCalledTimes(1); + }); + + it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => { + await createComponent(); + + const triggerElement = component.triggerElement().nativeElement; + jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({ + bottom: 0, + height: 0, + left: 0, + right: 320, + toJSON: () => ({}), + top: 0, + width: 320, + x: 0, + y: 0, + }); + + component.onMenuOpened(); + flushMicrotasks(); + tick(); + + expect(component.isMenuOpen()).toBe(true); + expect( + document.documentElement.style.getPropertyValue( + '--playlist-switcher-overlay-width' + ) + ).toBe('320px'); + expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1); + expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith( + xtreamPlaylist.serverUrl, + xtreamPlaylist.username, + xtreamPlaylist.password + ); + expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe('active'); + expect(component.getStatusClass(xtreamPlaylist._id)).toBe('status-active'); + + component.onMenuClosed(); + expect(component.isMenuOpen()).toBe(false); + expect( + document.documentElement.style.getPropertyValue( + '--playlist-switcher-overlay-width' + ) + ).toBe(''); + })); + + it('falls back to the active playlist title when no explicit current title is provided', async () => { + await createComponent(); + + expect(component.displayTitle()).toBe('Cinema Xtream'); + + resolvedPlaylistIdSignal.set(null); + activePlaylistSignal.set(null); + fixture.detectChanges(); + + expect(component.displayTitle()).toBe('Select playlist'); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts b/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts new file mode 100644 index 000000000..a78d29105 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts @@ -0,0 +1,422 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NavigationEnd, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { + PlaylistActions, + selectActivePlaylistId, + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from 'm3u-state'; +import { Subject } from 'rxjs'; +import { PlaylistMeta } from 'shared-interfaces'; +import { + PlaylistContextFacade, + PlaylistRouteContext, +} from './playlist-context.facade'; + +const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2'; + +function createPlaylist( + overrides: Partial & { _id: string } +): PlaylistMeta { + return { + _id: overrides._id, + title: overrides.title ?? overrides.filename ?? overrides._id, + count: overrides.count ?? 0, + importDate: + overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(), + autoRefresh: overrides.autoRefresh ?? false, + ...overrides, + } as PlaylistMeta; +} + +describe('PlaylistContextFacade', () => { + let facade: PlaylistContextFacade; + let routerEvents: Subject; + let router: { + url: string; + events: ReturnType['asObservable']>; + navigate: jest.Mock; + }; + let dispatch: jest.Mock; + let playlistsSignal: ReturnType>; + let loadedSignal: ReturnType>; + let activePlaylistIdSignal: ReturnType>; + let originalElectron: unknown; + + const xtreamA = createPlaylist({ + _id: 'xtream-a', + password: 'secret', + serverUrl: 'http://127.0.0.1:3211', + title: 'Xtream A', + username: 'user-a', + }); + const xtreamB = createPlaylist({ + _id: 'xtream-b', + password: 'secret', + serverUrl: 'http://127.0.0.1:3211', + title: 'Xtream B', + username: 'user-b', + }); + const stalkerA = createPlaylist({ + _id: 'stalker-a', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://127.0.0.1:3210/portal.php', + title: 'Stalker A', + }); + const stalkerB = createPlaylist({ + _id: 'stalker-b', + macAddress: '00:1A:79:00:00:02', + portalUrl: 'http://127.0.0.1:3210/portal.php', + title: 'Stalker B', + }); + const m3uA = createPlaylist({ + _id: 'm3u-a', + title: 'M3U A', + url: 'http://example.test/a.m3u', + }); + const m3uB = createPlaylist({ + _id: 'm3u-b', + title: 'M3U B', + url: 'http://example.test/b.m3u', + }); + + function setElectronAvailability(enabled: boolean): void { + Object.defineProperty(window, 'electron', { + configurable: true, + value: enabled ? {} : undefined, + }); + } + + function instantiateFacade(): PlaylistContextFacade { + facade = TestBed.inject(PlaylistContextFacade); + dispatch.mockClear(); + router.navigate.mockClear(); + return facade; + } + + beforeEach(() => { + localStorage.clear(); + originalElectron = (window as Window & { electron?: unknown }).electron; + + routerEvents = new Subject(); + router = { + url: '/workspace/xtreams/xtream-a/live', + events: routerEvents.asObservable(), + navigate: jest.fn().mockResolvedValue(true), + }; + dispatch = jest.fn((action: unknown) => action); + playlistsSignal = signal([ + xtreamA, + xtreamB, + stalkerA, + stalkerB, + m3uA, + m3uB, + ]); + loadedSignal = signal(true); + activePlaylistIdSignal = signal(xtreamA._id); + setElectronAvailability(true); + + TestBed.configureTestingModule({ + providers: [ + PlaylistContextFacade, + { + provide: Router, + useValue: router, + }, + { + provide: Store, + useValue: { + dispatch, + selectSignal: jest.fn((selector: unknown) => { + if (selector === selectAllPlaylistsMeta) { + return playlistsSignal; + } + if (selector === selectPlaylistsLoadingFlag) { + return loadedSignal; + } + if (selector === selectActivePlaylistId) { + return activePlaylistIdSignal; + } + + throw new Error( + `Unexpected selector: ${String(selector)}` + ); + }), + }, + }, + ], + }); + }); + + afterEach(() => { + localStorage.clear(); + Object.defineProperty(window, 'electron', { + configurable: true, + value: originalElectron, + }); + routerEvents.complete(); + jest.restoreAllMocks(); + }); + + it('preserves same-provider Xtream sections when switching through selectPlaylist', () => { + router.url = '/workspace/xtreams/xtream-a/live'; + const service = instantiateFacade(); + + service.selectPlaylist(xtreamB); + + expect(dispatch).toHaveBeenCalledWith( + PlaylistActions.setActivePlaylist({ playlistId: xtreamB._id }) + ); + expect(router.navigate).toHaveBeenCalledWith([ + 'workspace', + 'xtreams', + xtreamB._id, + 'live', + ]); + }); + + it('preserves same-provider Stalker sections when resolving target commands', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('stalker', stalkerB._id, { + inWorkspace: true, + provider: 'stalker', + playlistId: stalkerA._id, + section: 'downloads', + }) + ).toEqual(['workspace', 'stalker', stalkerB._id, 'downloads']); + }); + + it('preserves same-provider M3U sections when resolving target commands', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('playlists', m3uB._id, { + inWorkspace: true, + provider: 'playlists', + playlistId: m3uA._id, + section: 'groups', + }) + ).toEqual(['workspace', 'playlists', m3uB._id, 'groups']); + }); + + it('maps Stalker ITV routes to Xtream live routes', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('xtreams', xtreamA._id, { + inWorkspace: true, + provider: 'stalker', + playlistId: stalkerA._id, + section: 'itv', + }) + ).toEqual(['workspace', 'xtreams', xtreamA._id, 'live']); + }); + + it('maps Xtream live routes to Stalker ITV routes', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('stalker', stalkerA._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'live', + }) + ).toEqual(['workspace', 'stalker', stalkerA._id, 'itv']); + }); + + it('maps Xtream recently-added routes to Stalker recent routes', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('stalker', stalkerA._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'recently-added', + }) + ).toEqual(['workspace', 'stalker', stalkerA._id, 'recent']); + }); + + it('preserves recent and favorites sections across provider changes when both providers support them', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('xtreams', xtreamA._id, { + inWorkspace: true, + provider: 'playlists', + playlistId: m3uA._id, + section: 'recent', + }) + ).toEqual(['workspace', 'xtreams', xtreamA._id, 'recent']); + expect( + service.resolveTargetCommands('stalker', stalkerA._id, { + inWorkspace: true, + provider: 'playlists', + playlistId: m3uA._id, + section: 'favorites', + }) + ).toEqual(['workspace', 'stalker', stalkerA._id, 'favorites']); + }); + + it('falls back to M3U all when the current section has no M3U equivalent', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('playlists', m3uA._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'live', + }) + ).toEqual(['workspace', 'playlists', m3uA._id, 'all']); + }); + + it('does not let stale M3U memory override the fallback to all for portal-only sections', () => { + localStorage.setItem( + LAST_SECTION_STORAGE_KEY, + JSON.stringify({ + providers: { + playlists: 'recent', + }, + playlists: { + [m3uA._id]: { + provider: 'playlists', + section: 'recent', + updatedAt: 1, + }, + }, + }) + ); + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('playlists', m3uA._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'live', + }) + ).toEqual(['workspace', 'playlists', m3uA._id, 'all']); + }); + + it('uses playlist-specific section memory when the current route has no section', () => { + localStorage.setItem( + LAST_SECTION_STORAGE_KEY, + JSON.stringify({ + providers: {}, + playlists: { + [xtreamB._id]: { + provider: 'xtreams', + section: 'series', + updatedAt: 1, + }, + }, + }) + ); + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('xtreams', xtreamB._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: null, + }) + ).toEqual(['workspace', 'xtreams', xtreamB._id, 'series']); + }); + + it('uses provider-level section memory when no route section or playlist memory exists', () => { + localStorage.setItem( + LAST_SECTION_STORAGE_KEY, + JSON.stringify({ + providers: { + stalker: 'search', + }, + playlists: {}, + }) + ); + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('stalker', stalkerB._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: null, + }) + ).toEqual(['workspace', 'stalker', stalkerB._id, 'search']); + }); + + it('omits Xtream sections outside Electron where section navigation is unsupported', () => { + setElectronAvailability(false); + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('xtreams', xtreamB._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'live', + }) + ).toEqual(['workspace', 'xtreams', xtreamB._id]); + }); + + it('returns null when switching from the workspace root before a provider route is selected', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('playlists', m3uA._id, { + inWorkspace: true, + provider: null, + playlistId: null, + section: null, + }) + ).toBeNull(); + }); + + it('syncs section memory from router navigation events', () => { + router.url = '/workspace/playlists/m3u-a/favorites'; + const service = instantiateFacade(); + + router.url = '/workspace/xtreams/xtream-a/recently-added'; + routerEvents.next( + new NavigationEnd( + 1, + '/workspace/xtreams/xtream-a/recently-added', + '/workspace/xtreams/xtream-a/recently-added' + ) + ); + + expect(service.routeContext()).toEqual({ + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'recently-added', + }); + expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}')) + .toEqual({ + playlists: { + [m3uA._id]: { + provider: 'playlists', + section: 'favorites', + updatedAt: expect.any(Number), + }, + [xtreamA._id]: { + provider: 'xtreams', + section: 'recently-added', + updatedAt: expect.any(Number), + }, + }, + providers: { + playlists: 'favorites', + xtreams: 'recently-added', + }, + }); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/playlist-context.facade.ts b/libs/playlist/shared/util/src/lib/playlist-context.facade.ts index 1f45d6056..99ee469c6 100644 --- a/libs/playlist/shared/util/src/lib/playlist-context.facade.ts +++ b/libs/playlist/shared/util/src/lib/playlist-context.facade.ts @@ -312,6 +312,10 @@ export class PlaylistContextFacade { return fromCurrent; } + if (provider === 'playlists' && currentSection) { + return 'all'; + } + const memory = this.readSectionMemory(); const playlistMemory = memory.playlists[targetPlaylistId]; if (playlistMemory?.provider === provider) { diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index 3efd82a4a..ab36f82ea 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -7,9 +7,13 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatTooltip } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; +import { + ActivatedRoute, + convertToParamMap, + Router, +} from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { ReplaySubject, of } from 'rxjs'; import { PORTAL_CATALOG_DETAIL_COMPONENT, PORTAL_CATALOG_FACADE, @@ -54,6 +58,8 @@ class MockDetailComponent {} describe('CategoryContentViewComponent', () => { let fixture: ComponentFixture; + const paramMap$ = new ReplaySubject(1); + const queryParamMap$ = new ReplaySubject(1); const isPaginatedContentLoading = signal(true); const categoryItemCount = signal(0); const contentSortMode = signal(null); @@ -73,6 +79,7 @@ describe('CategoryContentViewComponent', () => { playlist: signal(null), isPaginatedContentLoading, initialize: jest.fn(), + setSearchQuery: jest.fn(), clearSelectedItem: jest.fn(), setPage: jest.fn(), setLimit: jest.fn(), @@ -86,6 +93,9 @@ describe('CategoryContentViewComponent', () => { categoryItemCount.set(0); contentSortMode.set(null); catalog.initialize.mockClear(); + catalog.setSearchQuery.mockClear(); + paramMap$.next(convertToParamMap({})); + queryParamMap$.next(convertToParamMap({})); await TestBed.configureTestingModule({ imports: [CategoryContentViewComponent, NoopAnimationsModule], @@ -127,6 +137,8 @@ describe('CategoryContentViewComponent', () => { { provide: ActivatedRoute, useValue: { + paramMap: paramMap$.asObservable(), + queryParamMap: queryParamMap$.asObservable(), snapshot: { params: {}, }, @@ -172,4 +184,17 @@ describe('CategoryContentViewComponent', () => { 'Fetching playlist data from source...' ); }); + + it('forwards query-param search updates to the catalog facade when supported', () => { + fixture.detectChanges(); + catalog.setSearchQuery.mockClear(); + + queryParamMap$.next( + convertToParamMap({ + q: 'matrix', + }) + ); + + expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix'); + }); }); diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 7d17b2822..0f4dd309e 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -1,5 +1,12 @@ import { NgComponentOutlet } from '@angular/common'; -import { Component, computed, inject, OnInit } from '@angular/core'; +import { + Component, + computed, + DestroyRef, + inject, + OnInit, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; @@ -49,6 +56,7 @@ interface CategoryContentItem { }) export class CategoryContentViewComponent implements OnInit { private readonly activatedRoute = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade< @@ -102,9 +110,18 @@ export class CategoryContentViewComponent implements OnInit { } ngOnInit(): void { - const { categoryId } = this.activatedRoute.snapshot.params; - this.catalog.initialize(categoryId ?? null); - this.openStalkerItemFromNavigationState(); + this.activatedRoute.paramMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((params) => { + this.catalog.initialize(params.get('categoryId')); + this.openStalkerItemFromNavigationState(); + }); + + this.activatedRoute.queryParamMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((params) => { + this.catalog.setSearchQuery?.(params.get('q') ?? ''); + }); } onPageChange(event: PageEvent): void { diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts index 1cdd1afd8..c180481df 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts @@ -3,10 +3,10 @@ import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute } from '@angular/router'; -import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui'; -import { ResizableDirective } from 'components'; import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ResizableDirective } from 'components'; import { StalkerPortalItem, XtreamCategory } from 'shared-interfaces'; import { CategoryViewComponent } from '../category-view/category-view.component'; import { ContentCardComponent } from '../content-card/content-card.component'; @@ -59,9 +59,7 @@ export interface FavoriteLayoutItem { ], }) export class FavoritesLayoutComponent { - readonly isWorkspaceLayout = isWorkspaceLayoutRoute( - inject(ActivatedRoute) - ); + readonly isWorkspaceLayout = isWorkspaceLayoutRoute(inject(ActivatedRoute)); readonly categories = input([]); readonly favorites = input([]); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index ce0c806fd..a667136db 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -20,7 +20,7 @@ import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from 'm3u-state'; -import { of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from 'shared-interfaces'; import { UnifiedCollectionPageComponent } from './unified-collection-page.component'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; @@ -59,6 +59,28 @@ class StubUnifiedGridTabComponent { describe('UnifiedCollectionPageComponent', () => { let fixture: ComponentFixture; + let route: ActivatedRoute & { + snapshot: { + paramMap: ReturnType; + queryParamMap: ReturnType; + queryParams: Record; + params: Record; + data: Record; + parent: null; + }; + paramMap: ReturnType< + BehaviorSubject>['asObservable'] + >; + queryParamMap: ReturnType< + BehaviorSubject>['asObservable'] + >; + pathFromRoot: ActivatedRoute[]; + }; + let routeParamMap$: BehaviorSubject>; + let routeQueryParamMap$: BehaviorSubject< + ReturnType + >; + let workspaceParamMap$: BehaviorSubject>; const playlistsLoaded = signal(false); const playlists = signal([]); const favoritesData = { @@ -72,10 +94,83 @@ describe('UnifiedCollectionPageComponent', () => { clearRecentItems: jest.fn(), }; + function toParamMapRecord( + values: Record + ): Record { + return Object.fromEntries( + Object.entries(values) + .filter(([, value]) => value != null) + .map(([key, value]) => [key, String(value)]) + ); + } + + function setRouteParams(params: Record): void { + const nextParamMap = convertToParamMap(toParamMapRecord(params)); + route.snapshot.params = params; + route.snapshot.paramMap = nextParamMap; + routeParamMap$.next(nextParamMap); + } + + function setRouteQueryParams(queryParams: Record): void { + const nextQueryParamMap = convertToParamMap( + toParamMapRecord(queryParams) + ); + route.snapshot.queryParams = queryParams; + route.snapshot.queryParamMap = nextQueryParamMap; + routeQueryParamMap$.next(nextQueryParamMap); + } + beforeEach(async () => { playlistsLoaded.set(false); playlists.set([]); jest.clearAllMocks(); + routeParamMap$ = new BehaviorSubject(convertToParamMap({})); + routeQueryParamMap$ = new BehaviorSubject(convertToParamMap({})); + workspaceParamMap$ = new BehaviorSubject(convertToParamMap({})); + + const workspaceRoute = { + snapshot: { + data: { layout: 'workspace' }, + paramMap: convertToParamMap({}), + params: {}, + }, + paramMap: workspaceParamMap$.asObservable(), + } as ActivatedRoute; + + route = { + snapshot: { + paramMap: convertToParamMap({}), + queryParamMap: convertToParamMap({}), + queryParams: {}, + params: {}, + data: {}, + parent: null, + }, + paramMap: routeParamMap$.asObservable(), + queryParamMap: routeQueryParamMap$.asObservable(), + pathFromRoot: [], + } as ActivatedRoute & { + snapshot: { + paramMap: ReturnType; + queryParamMap: ReturnType; + queryParams: Record; + params: Record; + data: Record; + parent: null; + }; + paramMap: ReturnType< + BehaviorSubject< + ReturnType + >['asObservable'] + >; + queryParamMap: ReturnType< + BehaviorSubject< + ReturnType + >['asObservable'] + >; + pathFromRoot: ActivatedRoute[]; + }; + route.pathFromRoot = [workspaceRoute, route]; await TestBed.configureTestingModule({ imports: [ @@ -85,23 +180,7 @@ describe('UnifiedCollectionPageComponent', () => { providers: [ { provide: ActivatedRoute, - useValue: { - snapshot: { - queryParamMap: convertToParamMap({}), - queryParams: {}, - params: {}, - data: {}, - parent: null, - }, - queryParamMap: of(convertToParamMap({})), - pathFromRoot: [ - { - snapshot: { - data: { layout: 'workspace' }, - }, - }, - ], - }, + useValue: route, }, { provide: Store, @@ -184,4 +263,98 @@ describe('UnifiedCollectionPageComponent', () => { undefined ); }); + + it('does not reload when local item state changes on empty playlist favorites', async () => { + setRouteParams({ id: 'playlist-1' }); + setRouteQueryParams({ scope: 'playlist' }); + playlistsLoaded.set(true); + playlists.set([ + { + _id: 'playlist-1', + title: 'Playlist One', + count: 0, + importDate: '2026-04-05T20:00:00.000Z', + autoRefresh: false, + favorites: [], + } as PlaylistMeta, + ]); + + fixture.componentRef.setInput('portalType', 'm3u'); + fixture.componentRef.setInput('defaultScope', undefined); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1); + expect(favoritesData.getFavorites).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-1', + 'm3u' + ); + + fixture.componentInstance.allItems.set([]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1); + }); + + it('reloads favorites when the playlist id changes in place', async () => { + setRouteParams({ id: 'playlist-1' }); + setRouteQueryParams({ scope: 'playlist' }); + playlistsLoaded.set(true); + fixture.componentRef.setInput('portalType', 'm3u'); + fixture.componentRef.setInput('defaultScope', undefined); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1); + expect(favoritesData.getFavorites).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-1', + 'm3u' + ); + + setRouteParams({ id: 'playlist-2' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(favoritesData.getFavorites).toHaveBeenCalledTimes(2); + expect(favoritesData.getFavorites).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-2', + 'm3u' + ); + }); + + it('reloads recent items when the playlist id changes in place', async () => { + setRouteParams({ id: 'playlist-1' }); + setRouteQueryParams({ scope: 'playlist' }); + playlistsLoaded.set(true); + fixture.componentRef.setInput('mode', 'recent'); + fixture.componentRef.setInput('portalType', 'm3u'); + fixture.componentRef.setInput('defaultScope', undefined); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(recentData.getRecentItems).toHaveBeenCalledTimes(1); + expect(recentData.getRecentItems).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-1', + 'm3u' + ); + + setRouteParams({ id: 'playlist-2' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(recentData.getRecentItems).toHaveBeenCalledTimes(2); + expect(recentData.getRecentItems).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-2', + 'm3u' + ); + }); }); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index e3f8b3cea..a249ab2c3 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -4,9 +4,10 @@ import { computed, effect, inject, + linkedSignal, input, - OnInit, signal, + untracked, } from '@angular/core'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIconButton } from '@angular/material/button'; @@ -23,6 +24,7 @@ import { isWorkspaceLayoutRoute, OPEN_LIVE_COLLECTION_ITEM_STATE_KEY, queryParamSignal, + routeParamSignal, ScopeToggleService, UnifiedCollectionItem, UnifiedFavoritesDataService, @@ -50,9 +52,12 @@ import { UnifiedGridTabComponent } from './unified-grid-tab.component'; UnifiedLiveTabComponent, ], }) -export class UnifiedCollectionPageComponent implements OnInit { +export class UnifiedCollectionPageComponent { readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input(); + readonly playlistIdInput = input(undefined, { + alias: 'playlistId', + }); readonly defaultScope = input(); private readonly route = inject(ActivatedRoute); @@ -65,11 +70,25 @@ export class UnifiedCollectionPageComponent implements OnInit { selectPlaylistsLoadingFlag ); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); + private readonly queryScope = queryParamSignal( + this.route, + 'scope', + (value) => + value === 'all' || value === 'playlist' ? value : null + ); private readonly routeSearchTerm = queryParamSignal( this.route, 'q', (value) => (value ?? '').trim() ); + private readonly routePlaylistId = routeParamSignal( + this.route, + 'id', + (value) => value ?? undefined + ); + readonly playlistId = computed( + () => this.playlistIdInput() ?? this.routePlaylistId() + ); readonly workspaceSearchTerm = computed(() => this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); @@ -84,19 +103,27 @@ export class UnifiedCollectionPageComponent implements OnInit { readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i); readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i); - readonly playlistId = computed(() => { - let current = this.route.snapshot; - while (current) { - if (current.params['id']) { - return current.params['id'] as string; - } - current = current.parent!; - } - return undefined; - }); - readonly scopeKey = computed(() => this.mode()); - readonly scope = signal('playlist'); + private readonly persistedScope = computed(() => + this.scopeService.getScope(this.scopeKey())() + ); + readonly scope = linkedSignal(() => { + if (!this.showScopeToggle()) { + return 'all'; + } + + const queryScope = this.queryScope(); + if (queryScope) { + return queryScope; + } + + const defaultScope = this.defaultScope(); + if (defaultScope) { + return defaultScope; + } + + return this.persistedScope(); + }); readonly showScopeToggle = computed(() => Boolean(this.playlistId())); readonly effectiveScope = computed(() => this.showScopeToggle() ? this.scope() : 'all' @@ -153,36 +180,27 @@ export class UnifiedCollectionPageComponent implements OnInit { ) .join('|'); }); + private readonly loadRequest = computed(() => ({ + mode: this.mode(), + portalType: this.portalType(), + playlistId: this.playlistId(), + scope: this.effectiveScope(), + reloadKey: this.favoritesReloadKey(), + })); private loadRequestId = 0; - constructor() { - effect(() => { - this.mode(); - this.portalType(); - this.playlistId(); - this.effectiveScope(); - this.favoritesReloadKey(); - void this.loadData(); + private readonly loadEffect = effect(() => { + const { mode, portalType, playlistId, scope } = this.loadRequest(); + untracked(() => { + void this.loadData({ + mode, + portalType, + playlistId, + scope, + }); }); - } - - ngOnInit(): void { - const queryScope = this.route.snapshot.queryParams['scope'] as - | CollectionScope - | undefined; - if ( - this.showScopeToggle() && - (queryScope === 'all' || queryScope === 'playlist') - ) { - this.scope.set(queryScope); - } else if (this.defaultScope()) { - this.scope.set(this.defaultScope()!); - } else { - const persisted = this.scopeService.getScope(this.scopeKey()); - this.scope.set(persisted()); - } - } + }); onScopeChange(value: CollectionScope): void { if (!this.showScopeToggle()) { @@ -243,20 +261,30 @@ export class UnifiedCollectionPageComponent implements OnInit { }); } - private async loadData(): Promise { + private async loadData(params: { + mode: 'favorites' | 'recent'; + portalType?: string; + playlistId?: string; + scope: CollectionScope; + }): Promise { const requestId = ++this.loadRequestId; if (this.allItems().length === 0) { this.isLoading.set(true); } try { - const s = this.effectiveScope(); - const pid = this.playlistId(); - const pt = this.portalType(); const items = - this.mode() === 'favorites' - ? await this.favoritesData.getFavorites(s, pid, pt) - : await this.recentData.getRecentItems(s, pid, pt); + params.mode === 'favorites' + ? await this.favoritesData.getFavorites( + params.scope, + params.playlistId, + params.portalType + ) + : await this.recentData.getRecentItems( + params.scope, + params.playlistId, + params.portalType + ); if (requestId !== this.loadRequestId) { return; } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index ddfd3136c..25219e131 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -184,9 +184,15 @@ export class UnifiedLiveTabComponent { return; } - if (this.activeUid() === matchedItem.uid && this.activeDetail()) { - this.autoOpenHandled.emit(); - return; + if (this.activeUid() === matchedItem.uid) { + if (this.activeDetail()) { + this.autoOpenHandled.emit(); + return; + } + + if (this.isSelecting()) { + return; + } } void this.activateItem(matchedItem, true); @@ -200,7 +206,9 @@ export class UnifiedLiveTabComponent { } async onChannelSelected(channel: UnifiedFavoriteChannel): Promise { - const item = this.items().find((candidate) => candidate.uid === channel.uid); + const item = this.items().find( + (candidate) => candidate.uid === channel.uid + ); if (!item) { return; } @@ -208,7 +216,9 @@ export class UnifiedLiveTabComponent { } onFavoriteToggled(channel: UnifiedFavoriteChannel): void { - const item = this.items().find((candidate) => candidate.uid === channel.uid); + const item = this.items().find( + (candidate) => candidate.uid === channel.uid + ); if (item) { this.removeItem.emit(item); } @@ -230,9 +240,7 @@ export class UnifiedLiveTabComponent { this.activeUid.set(null); } - private async loadEpgMap( - items: UnifiedCollectionItem[] - ): Promise { + private async loadEpgMap(items: UnifiedCollectionItem[]): Promise { const epgMap = await this.streamResolver.loadEpgForItems(items); this.epgMap.set(epgMap); } diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts index dde8c52e9..c0d536ea9 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts @@ -1,6 +1,9 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; -import { XtreamApiService, XtreamUrlService } from '@iptvnator/portal/xtream/data-access'; +import { + XtreamApiService, + XtreamUrlService, +} from '@iptvnator/portal/xtream/data-access'; import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; import { DataService, PlaylistsService } from 'services'; import { Playlist } from 'shared-interfaces'; @@ -304,6 +307,70 @@ describe('StreamResolverService', () => { expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1); }); + it('falls back to empty Xtream EPG when the provider response is too slow', async () => { + jest.useFakeTimers(); + + try { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'xtream-1', + serverUrl: 'https://xtream.example.com', + username: 'user', + password: 'pass', + } satisfies Partial) + ); + xtreamUrl.constructLiveUrl.mockReturnValue( + 'https://xtream.example.com/live/1' + ); + xtreamApi.getShortEpg.mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + { + id: '1', + epg_id: '', + title: 'Late Xtream Show', + description: 'Delayed Xtream program', + lang: '', + start: '2026-03-26T11:00:00.000Z', + end: '2026-03-26T12:00:00.000Z', + stop: '2026-03-26T12:00:00.000Z', + channel_id: '1', + start_timestamp: '1774522800', + stop_timestamp: '1774526400', + }, + ]), + 10_000 + ); + }) + ); + + const detailPromise = service.resolveLiveDetail({ + uid: 'xtream::xtream-1::1', + name: 'Xtream Live', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 1, + } satisfies UnifiedCollectionItem); + + await jest.advanceTimersByTimeAsync(3000); + + await expect(detailPromise).resolves.toMatchObject({ + epgMode: 'portal', + playback: expect.objectContaining({ + streamUrl: 'https://xtream.example.com/live/1', + }), + epgItems: [], + }); + } finally { + jest.useRealTimers(); + } + }); + it('loads current Stalker EPG previews for live collection rows', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ @@ -322,12 +389,8 @@ describe('StreamResolverService', () => { time: '2026-03-26T11:00:00.000Z', time_to: '2026-03-26T12:00:00.000Z', ch_id: '77', - start_timestamp: String( - Math.floor(Date.now() / 1000) - 60 - ), - stop_timestamp: String( - Math.floor(Date.now() / 1000) + 60 - ), + start_timestamp: String(Math.floor(Date.now() / 1000) - 60), + stop_timestamp: String(Math.floor(Date.now() / 1000) + 60), }, ], }); diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts index d0b3d37a3..b5843ae43 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts @@ -61,11 +61,29 @@ export class StreamResolverService { private readonly dataService = inject(DataService); private readonly stalkerSession = inject(StalkerSessionService); private readonly m3uEpgTimeoutMs = 3000; + private readonly portalEpgTimeoutMs = 3000; private readonly xtreamEpgCache = new Map(); private readonly xtreamEpgFailureTimestamps = new Map(); private readonly xtreamEpgCacheTtlMs = 60 * 1000; private readonly xtreamEpgFailureCooldownMs = 60 * 1000; + private async getElectronPlaylist( + playlistId: string + ): Promise { + if (typeof window === 'undefined') { + return undefined; + } + + try { + return ( + (await window.electron?.dbGetAppPlaylist?.(playlistId)) ?? + undefined + ); + } catch { + return undefined; + } + } + async resolvePlayback( item: UnifiedCollectionItem ): Promise { @@ -159,12 +177,7 @@ export class StreamResolverService { for (const [playlistId, playlistItems] of stalkerByPlaylist.entries()) { tasks.push( - this.loadStalkerEpgBatch( - playlistId, - playlistItems, - epgMap, - now - ) + this.loadStalkerEpgBatch(playlistId, playlistItems, epgMap, now) ); } @@ -182,7 +195,11 @@ export class StreamResolverService { item: UnifiedCollectionItem ): Promise { const playback = await this.resolveXtream(item); - const epgItems = await this.loadXtreamEpgItems(item); + const epgItems = await this.withFallbackTimeout( + this.loadXtreamEpgItems(item), + this.portalEpgTimeoutMs, + [] + ); return { playback, @@ -195,7 +212,11 @@ export class StreamResolverService { item: UnifiedCollectionItem ): Promise { const playback = await this.resolveStalker(item); - const epgItems = await this.loadStalkerEpgItems(item, 10); + const epgItems = await this.withFallbackTimeout( + this.loadStalkerEpgItems(item, 10), + this.portalEpgTimeoutMs, + [] + ); return { playback, @@ -325,9 +346,10 @@ export class StreamResolverService { )) as Playlist | undefined; const channelId = String( item.stalkerId ?? - ((item.stalkerItem as Record | undefined)?.[ + (item.stalkerItem as Record | undefined)?.[ 'id' - ] ?? '') + ] ?? + '' ).trim(); if (!playlist || !channelId) { @@ -338,9 +360,11 @@ export class StreamResolverService { } private async getXtreamCredentials(playlistId: string) { - const playlist = (await firstValueFrom( - this.playlistsService.getPlaylistById(playlistId) - )) as Playlist | undefined; + const playlist = + (await this.getElectronPlaylist(playlistId)) ?? + ((await firstValueFrom( + this.playlistsService.getPlaylistById(playlistId) + )) as Playlist | undefined); if (!playlist?.serverUrl || !playlist.username || !playlist.password) { return null; @@ -378,7 +402,9 @@ export class StreamResolverService { this.findM3uChannel(playlist?.playlist?.items ?? [], item) ?? this.buildFallbackM3uChannel(item); const epgPrograms = includePrograms - ? await this.fetchM3uPrograms(this.getM3uEpgLookupKey(channel, item)) + ? await this.fetchM3uPrograms( + this.getM3uEpgLookupKey(channel, item) + ) : []; return { @@ -467,7 +493,8 @@ export class StreamResolverService { Number(item.start_timestamp) <= nowSeconds && nowSeconds < Number(item.stop_timestamp) ) ?? null; - const epgKey = channel.tvgId?.trim() || channel.name?.trim(); + const epgKey = + channel.tvgId?.trim() || channel.name?.trim(); if (!epgKey) { return; @@ -491,7 +518,8 @@ export class StreamResolverService { : null ); } catch { - const epgKey = channel.tvgId?.trim() || channel.name?.trim(); + const epgKey = + channel.tvgId?.trim() || channel.name?.trim(); if (epgKey) { epgMap.set(epgKey, null); } @@ -546,11 +574,7 @@ export class StreamResolverService { streamId: number, limit: number ): Promise { - const cacheKey = this.getXtreamEpgCacheKey( - playlistId, - streamId, - limit - ); + const cacheKey = this.getXtreamEpgCacheKey(playlistId, streamId, limit); const cached = this.getCachedXtreamEpgItems(cacheKey); if (cached !== null) { return cached; @@ -599,7 +623,8 @@ export class StreamResolverService { await Promise.all( channels.map(async (channel) => { const channelId = String(channel.stalkerId ?? '').trim(); - const epgKey = channel.tvgId?.trim() || channelId || channel.name?.trim(); + const epgKey = + channel.tvgId?.trim() || channelId || channel.name?.trim(); if (!channelId || !epgKey) { return; @@ -652,7 +677,7 @@ export class StreamResolverService { const epgData = Array.isArray(response?.js) ? response.js - : response?.js?.data ?? []; + : (response?.js?.data ?? []); return epgData.map((item) => ({ id: String(item.id ?? ''), diff --git a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts index 9618c4473..57c59b5d9 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts @@ -11,7 +11,11 @@ import { GlobalFavoritesListComponent, UnifiedLiveTabComponent, } from '@iptvnator/portal/shared/ui'; -import { ArtPlayerComponent, HtmlVideoPlayerComponent, VjsPlayerComponent } from '@iptvnator/ui/playback'; +import { + ArtPlayerComponent, + HtmlVideoPlayerComponent, + VjsPlayerComponent, +} from '@iptvnator/ui/playback'; import { EpgListComponent } from '@iptvnator/ui/epg'; import { ResizableDirective } from 'components'; import { SettingsStore } from 'services'; @@ -233,9 +237,7 @@ describe('UnifiedLiveTabComponent', () => { expect( fixture.nativeElement.querySelector('app-epg-list') ).not.toBeNull(); - expect( - fixture.nativeElement.querySelector('app-epg-view') - ).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); }); it('does not wait for M3U program lookup before opening playback', async () => { @@ -321,9 +323,7 @@ describe('UnifiedLiveTabComponent', () => { expect( fixture.nativeElement.querySelector('app-epg-view') ).not.toBeNull(); - expect( - fixture.nativeElement.querySelector('app-epg-list') - ).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); }); it('renders shared EPG view for Stalker items and records recent history', async () => { @@ -354,6 +354,69 @@ describe('UnifiedLiveTabComponent', () => { fixture.nativeElement.querySelector('app-epg-view') ).not.toBeNull(); }); + + it('does not restart auto-open while the same live item is still resolving', async () => { + const item = buildLiveItem('xtream'); + const pendingDetail = createDeferred<{ + epgMode: 'portal'; + playback: { + streamUrl: string; + title: string; + }; + epgItems: EpgItem[]; + }>(); + const autoOpenHandledSpy = jest.spyOn( + component.autoOpenHandled, + 'emit' + ); + + streamResolver.resolveLiveDetail.mockReturnValue(pendingDetail.promise); + recentData.recordLivePlayback.mockResolvedValue({ + ...item, + viewedAt: '2026-03-26T12:00:00.000Z', + }); + + fixture.componentRef.setInput('items', [item]); + fixture.componentRef.setInput('autoOpenItem', { + contentType: 'live', + sourceType: 'xtream', + playlistId: item.playlistId, + itemId: String(item.xtreamId), + title: item.name, + imageUrl: item.logo, + }); + fixture.detectChanges(); + await Promise.resolve(); + + expect(streamResolver.resolveLiveDetail).toHaveBeenCalledTimes(1); + expect(component.isSelecting()).toBe(true); + + fixture.componentRef.setInput('items', [{ ...item }]); + fixture.detectChanges(); + await Promise.resolve(); + + expect(streamResolver.resolveLiveDetail).toHaveBeenCalledTimes(1); + + pendingDetail.resolve({ + epgMode: 'portal', + playback: { + streamUrl: 'https://example.com/xtream.m3u8', + title: 'Xtream Live', + }, + epgItems: [buildEpgItem('Xtream Show')], + }); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.activeDetail()).toEqual( + expect.objectContaining({ + playback: expect.objectContaining({ + streamUrl: 'https://example.com/xtream.m3u8', + }), + }) + ); + expect(autoOpenHandledSpy).toHaveBeenCalledTimes(1); + }); }); function buildLiveItem( diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts index dfd4eefa9..a4881be07 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts @@ -11,6 +11,7 @@ import { extractPortalSection, isWorkspaceLayoutRoute, queryParamSignal, + routeParamSignal, resolveCurrentPortalPlaylistId, resolveCurrentPortalSection, } from './portal-route.utils'; @@ -226,4 +227,110 @@ describe('portal-route.utils', () => { childInjector.destroy(); }); + + it('creates route-param signals that react to current route param changes', () => { + TestBed.configureTestingModule({}); + + const parentInjector = TestBed.inject(EnvironmentInjector); + const childInjector = createEnvironmentInjector([], parentInjector); + const paramMap$ = new BehaviorSubject( + convertToParamMap({ + id: 'playlist-1', + }) + ); + const route = { + snapshot: { + data: {}, + paramMap: convertToParamMap({ + id: 'playlist-1', + }), + params: { + id: 'playlist-1', + }, + }, + paramMap: paramMap$.asObservable(), + } as unknown as ActivatedRoute; + + const playlistId = runInInjectionContext(childInjector, () => + routeParamSignal(route, 'id', (value) => value ?? '') + ); + + expect(playlistId()).toBe('playlist-1'); + + route.snapshot.paramMap = convertToParamMap({ + id: 'playlist-2', + }); + route.snapshot.params = { + id: 'playlist-2', + }; + paramMap$.next( + convertToParamMap({ + id: 'playlist-2', + }) + ); + + expect(playlistId()).toBe('playlist-2'); + + childInjector.destroy(); + }); + + it('creates route-param signals that react to ancestor route param changes', () => { + TestBed.configureTestingModule({}); + + const parentInjector = TestBed.inject(EnvironmentInjector); + const childInjector = createEnvironmentInjector([], parentInjector); + const parentParamMap$ = new BehaviorSubject( + convertToParamMap({ + id: 'playlist-1', + }) + ); + const childParamMap$ = new BehaviorSubject(convertToParamMap({})); + const parentRoute = { + snapshot: { + data: {}, + paramMap: convertToParamMap({ + id: 'playlist-1', + }), + params: { + id: 'playlist-1', + }, + }, + paramMap: parentParamMap$.asObservable(), + } as ActivatedRoute; + const route = { + snapshot: { + data: {}, + paramMap: convertToParamMap({}), + params: {}, + }, + paramMap: childParamMap$.asObservable(), + pathFromRoot: [] as ActivatedRoute[], + } as ActivatedRoute & { + pathFromRoot: ActivatedRoute[]; + }; + route.pathFromRoot = [parentRoute, route]; + + const playlistId = runInInjectionContext(childInjector, () => + routeParamSignal(route, 'id', (value) => value ?? '') + ); + + expect(playlistId()).toBe('playlist-1'); + + (parentRoute.snapshot as ActivatedRoute['snapshot']).paramMap = + convertToParamMap({ + id: 'playlist-2', + }); + (parentRoute.snapshot as ActivatedRoute['snapshot']).params = { + id: 'playlist-2', + }; + parentParamMap$.next( + convertToParamMap({ + id: 'playlist-2', + }) + ); + + expect(playlistId()).toBe('playlist-2'); + + childInjector.destroy(); + }); }); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts index 1cdea2834..9a2c81235 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts @@ -1,13 +1,14 @@ import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; -import { map } from 'rxjs'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { combineLatest, map, of } from 'rxjs'; import { PortalProvider, PortalRailSection, } from './portal-rail-links'; type QueryParamNormalizer = (value: string | null) => T; +type RouteParamNormalizer = (value: string | null) => T; const PORTAL_SECTIONS = new Set([ 'all', @@ -54,6 +55,37 @@ export function queryParamSignal( ); } +export function routeParamSignal( + route: ActivatedRoute, + key: string, + normalizer?: RouteParamNormalizer +): Signal { + const normalize = + normalizer ?? + ((( + value: string | null + ) => (value ?? '') as T) as RouteParamNormalizer); + const routeChain = + Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0 + ? route.pathFromRoot + : [route]; + + return toSignal( + combineLatest( + routeChain.map((currentRoute) => + currentRoute.paramMap ?? + of( + currentRoute.snapshot.paramMap ?? + convertToParamMap(currentRoute.snapshot.params ?? {}) + ) + ) + ).pipe(map(() => normalize(resolveCurrentRouteParam(route, key)))), + { + initialValue: normalize(resolveCurrentRouteParam(route, key)), + } + ); +} + export function extractPortalSection( url: string, provider: PortalProvider @@ -76,6 +108,18 @@ export function resolveCurrentPortalPlaylistId( route: ActivatedRoute, routerUrl: string, provider: PortalProvider +): string | null { + const resolved = resolveCurrentRouteParam(route, 'id'); + if (resolved) { + return resolved; + } + + return extractPortalPlaylistId(routerUrl, provider); +} + +function resolveCurrentRouteParam( + route: ActivatedRoute, + key: string ): string | null { const routeChain = Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0 @@ -84,15 +128,15 @@ export function resolveCurrentPortalPlaylistId( for (let index = routeChain.length - 1; index >= 0; index -= 1) { const currentRoute = routeChain[index]; - const playlistId = - currentRoute.snapshot.paramMap.get('id') ?? - currentRoute.snapshot.params['id']; - if (playlistId) { - return playlistId; + const value = + currentRoute.snapshot.paramMap?.get(key) ?? + currentRoute.snapshot.params?.[key]; + if (value) { + return value; } } - return extractPortalPlaylistId(routerUrl, provider); + return null; } export function resolveCurrentPortalSection( diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index a7f6c4957..debb0ae5a 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -46,6 +46,7 @@ export interface PortalCatalogFacade< readonly playlist: Signal; initialize(categoryId?: string | null): void; + setSearchQuery?(query: string): void; clearSelectedItem(): void; setPage(page: number): void; setLimit(limit: number): void; diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 63664f02c..6d243d33f 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -62,6 +62,21 @@ export function withStalkerPlayer() { const normalized = String(value ?? '').trim(); return normalized || fallback; }; + const getSeriesRecentMetadata = ( + selectedContentType: StalkerContentType + ): { + category_id?: 'series'; + is_series?: true; + } => { + if (selectedContentType !== 'series') { + return {}; + } + + return { + category_id: 'series', + is_series: true, + }; + }; return signalStoreFeature( withMethods( ( @@ -399,6 +414,8 @@ export function withStalkerPlayer() { ) => { const playlistId = storeState.currentPlaylist()?._id; if (!playlistId) return; + const selectedContentType = + storeState.selectedContentType(); const recentlyViewedItem: { id: string; title: string; @@ -410,8 +427,9 @@ export function withStalkerPlayer() { title, category_id: resolveCategoryId( item.category_id, - storeState.selectedContentType() + selectedContentType ), + ...getSeriesRecentMetadata(selectedContentType), added_at: Date.now(), }; playlistService diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts index e4dbaef46..eb775c054 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts @@ -16,14 +16,25 @@ type RecentlyViewedPayload = StalkerPortalItem & { title?: string; }; -function resolveCategoryId( - value: unknown, - fallback: string -): string { +function resolveCategoryId(value: unknown, fallback: string): string { const normalized = String(value ?? '').trim(); return normalized || fallback; } +function getSeriesRecentMetadata(selectedContentType: string): { + category_id?: 'series'; + is_series?: true; +} { + if (selectedContentType !== 'series') { + return {}; + } + + return { + category_id: 'series', + is_series: true, + }; +} + /** * Recently-viewed concern methods. */ @@ -41,19 +52,18 @@ export function withStalkerRecent() { return { addToRecentlyViewed(item: RecentlyViewedPayload) { const portalId = storeContext.currentPlaylist()?._id; + const selectedContentType = + storeContext.selectedContentType(); const recentItem = { ...item, category_id: resolveCategoryId( item.category_id, - storeContext.selectedContentType() + selectedContentType ), + ...getSeriesRecentMetadata(selectedContentType), added_at: Date.now(), id: item.id ?? item.stream_id ?? '', - title: - item.title ?? - item.name ?? - item.o_name ?? - '', + title: item.title ?? item.name ?? item.o_name ?? '', }; playlistService .addPortalRecentlyViewed(portalId, recentItem) diff --git a/libs/portal/stalker/feature/jest.config.ts b/libs/portal/stalker/feature/jest.config.ts new file mode 100644 index 000000000..12455654a --- /dev/null +++ b/libs/portal/stalker/feature/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portal-stalker-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/stalker/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/portal/stalker/feature/project.json b/libs/portal/stalker/feature/project.json index 62a74dce7..820cd6c30 100644 --- a/libs/portal/stalker/feature/project.json +++ b/libs/portal/stalker/feature/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:portal", "domain:stalker", "type:feature"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/portal/stalker/feature/jest.config.ts", + "tsConfig": "libs/portal/stalker/feature/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts index a3fa11a89..c8fab48e0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts @@ -1,9 +1,12 @@ import { + inject, ChangeDetectionStrategy, Component, input, } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; +import { routeParamSignal } from '@iptvnator/portal/shared/util'; @Component({ selector: 'app-stalker-collection-route', @@ -12,11 +15,19 @@ import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class StalkerCollectionRouteComponent { + private readonly route = inject(ActivatedRoute); + readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input('stalker'); + readonly playlistId = routeParamSignal( + this.route, + 'id', + (value) => value ?? undefined + ); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts new file mode 100644 index 000000000..7cbd8a748 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts @@ -0,0 +1,131 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NavigationEnd, Router } from '@angular/router'; +import { Subject, of } from 'rxjs'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { PlaylistsService } from 'services'; +import { PlaylistMeta } from 'shared-interfaces'; +import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service'; + +const PLAYLIST_ID = 'stalker-1'; +const ACTIVE_PLAYLIST: PlaylistMeta = { + _id: PLAYLIST_ID, + filename: 'stalker.m3u', + macAddress: '00:1A:79:12:34:56', + portalUrl: 'http://localhost/stalker_portal/server/load.php', + title: 'Test Stalker', +} as PlaylistMeta; + +async function flushEffects(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function getStalkerSectionFromUrl(url: string): string | null { + const match = url.match( + /^\/workspace\/stalker\/[^/]+\/([^/?]+)(?:\/|$)/ + ); + + return match?.[1] ?? null; +} + +describe('StalkerWorkspaceRouteSession', () => { + const routerEvents = new Subject(); + const activePlaylist = signal(ACTIVE_PLAYLIST); + const selectedContentType = signal<'vod' | 'itv' | 'series'>('vod'); + + const playlistContext = { + activePlaylist, + syncFromUrl: jest.fn(), + }; + + const stalkerStore = { + resetCategories: jest.fn(), + setSelectedCategory: jest.fn(), + clearSelectedItem: jest.fn(), + setCurrentPlaylist: jest.fn().mockResolvedValue(undefined), + setSelectedContentType: jest.fn((type: 'vod' | 'itv' | 'series') => { + selectedContentType.set(type); + }), + setSearchPhrase: jest.fn(), + }; + + const playlistsService = { + getPlaylistById: jest.fn(() => of(ACTIVE_PLAYLIST)), + }; + + const router = { + url: `/workspace/stalker/${PLAYLIST_ID}/vod`, + events: routerEvents.asObservable(), + }; + + beforeEach(async () => { + router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`; + activePlaylist.set(ACTIVE_PLAYLIST); + selectedContentType.set('vod'); + + playlistContext.syncFromUrl.mockImplementation((url: string) => ({ + inWorkspace: true, + provider: 'stalker', + playlistId: PLAYLIST_ID, + section: getStalkerSectionFromUrl(url) as + | 'favorites' + | 'itv' + | 'recent' + | 'search' + | 'series' + | 'vod' + | null, + })); + + stalkerStore.resetCategories.mockClear(); + stalkerStore.setSelectedCategory.mockClear(); + stalkerStore.clearSelectedItem.mockClear(); + stalkerStore.setCurrentPlaylist.mockClear(); + stalkerStore.setSelectedContentType.mockClear(); + stalkerStore.setSearchPhrase.mockClear(); + playlistsService.getPlaylistById.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + StalkerWorkspaceRouteSession, + { + provide: PlaylistContextFacade, + useValue: playlistContext, + }, + { + provide: PlaylistsService, + useValue: playlistsService, + }, + { + provide: Router, + useValue: router, + }, + { + provide: StalkerStore, + useValue: stalkerStore, + }, + ], + }); + }); + + it('keeps the itv route selection after playlist bootstrap', async () => { + router.url = `/workspace/stalker/${PLAYLIST_ID}/itv`; + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(stalkerStore.resetCategories).toHaveBeenCalled(); + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + ACTIVE_PLAYLIST + ); + expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith('itv'); + expect(selectedContentType()).toBe('itv'); + expect( + stalkerStore.setSelectedContentType.mock.invocationCallOrder[0] + ).toBeGreaterThan( + stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0] + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/test-setup.ts b/libs/portal/stalker/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/stalker/feature/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/portal/stalker/feature/tsconfig.spec.json b/libs/portal/stalker/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/stalker/feature/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} 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 66652c060..aa650c3e5 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 @@ -45,6 +45,10 @@ export interface XtreamContentItem { rating: string; added: string; poster_url: string; + epg_channel_id?: string | null; + tv_archive?: number | null; + tv_archive_duration?: number | null; + direct_source?: string | null; xtream_id: number; type: string; added_at?: string; @@ -57,7 +61,6 @@ export interface XtreamContentItem { stream_id?: number; stream_icon?: string; custom_sid?: string; - direct_source?: string; rating_imdb?: string; // Global search result fields diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts new file mode 100644 index 000000000..598616b62 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts @@ -0,0 +1,251 @@ +import { TestBed } from '@angular/core/testing'; +import { DataService } from 'services'; +import { XTREAM_REQUEST } from 'shared-interfaces'; +import { XtreamApiService, XtreamCredentials } from './xtream-api.service'; + +describe('XtreamApiService', () => { + let service: XtreamApiService; + let dataService: { + sendIpcEvent: jest.Mock, [string, unknown]>; + }; + + const credentials: XtreamCredentials = { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }; + + beforeEach(() => { + dataService = { + sendIpcEvent: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + XtreamApiService, + { provide: DataService, useValue: dataService }, + ], + }); + + service = TestBed.inject(XtreamApiService); + }); + + it('falls back to the legacy full-epg action and normalizes the response', async () => { + dataService.sendIpcEvent.mockImplementation( + async (_type: string, payload: unknown) => { + const action = ( + payload as { + params: { action: string }; + } + ).params.action; + + if (action === 'get_simple_data_table') { + return { payload: { epg_listings: [] } }; + } + + return { + payload: { + epg_listings: [ + { + id: 'later', + epg_id: 'channel-101.mock', + title: Buffer.from('Later Show').toString( + 'base64' + ), + description: Buffer.from( + 'Later description' + ).toString('base64'), + start: '2026-04-04 11:00:00', + end: '2026-04-04 11:30:00', + start_timestamp: '1775300400', + stop_timestamp: '1775302200', + channel_id: 'channel-101.mock', + }, + { + id: 'current', + epg_id: 'channel-101.mock', + title: Buffer.from('Current Show').toString( + 'base64' + ), + description: Buffer.from( + 'Current description' + ).toString('base64'), + start: '2026-04-04 10:30:00', + end: '2026-04-04 11:00:00', + start_timestamp: '1775298600', + stop_timestamp: '1775300400', + channel_id: 'channel-101.mock', + }, + ], + }, + }; + } + ); + + const items = await service.getFullEpg(credentials, 101); + + expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith( + 1, + XTREAM_REQUEST, + expect.objectContaining({ + url: 'http://demo.example', + params: expect.objectContaining({ + action: 'get_simple_data_table', + stream_id: '101', + username: 'demo', + password: 'secret', + }), + }) + ); + expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith( + 2, + XTREAM_REQUEST, + expect.objectContaining({ + params: expect.objectContaining({ + action: 'get_simple_date_table', + }), + }) + ); + expect(items).toHaveLength(2); + expect(items.map((item) => item.title)).toEqual([ + 'Current Show', + 'Later Show', + ]); + expect(items[0]).toEqual( + expect.objectContaining({ + description: 'Current description', + start: '2026-04-04T10:30:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + start_timestamp: '1775298600', + stop_timestamp: '1775300400', + }) + ); + }); + + it('normalizes short epg items from unix timestamps for display', async () => { + const startTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + const stopTimestamp = Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ); + + dataService.sendIpcEvent.mockResolvedValue({ + payload: { + epg_listings: [ + { + id: 'current', + epg_id: 'channel-101.mock', + title: Buffer.from('Current Show').toString('base64'), + description: Buffer.from( + 'Current description' + ).toString('base64'), + start: '2026-04-05 03:00:00', + end: '2026-04-05 03:30:00', + start_timestamp: String(startTimestamp), + stop_timestamp: String(stopTimestamp), + channel_id: 'channel-101.mock', + }, + ], + }, + }); + + const items = await service.getShortEpg(credentials, 101, 4); + + expect(items).toEqual([ + expect.objectContaining({ + title: 'Current Show', + description: 'Current description', + start: '2026-04-05T05:30:00.000Z', + stop: '2026-04-05T06:00:00.000Z', + start_timestamp: String(startTimestamp), + stop_timestamp: String(stopTimestamp), + }), + ]); + }); + + it('falls back to parsed short epg date strings when unix timestamps are absent', async () => { + const rawStart = '2026-04-05 03:00:00'; + const rawStop = '2026-04-05 03:30:00'; + + dataService.sendIpcEvent.mockResolvedValue({ + payload: { + epg_listings: [ + { + id: 'current', + epg_id: 'channel-101.mock', + title: Buffer.from('Current Show').toString('base64'), + description: Buffer.from( + 'Current description' + ).toString('base64'), + start: rawStart, + end: rawStop, + channel_id: 'channel-101.mock', + }, + ], + }, + }); + + const items = await service.getShortEpg(credentials, 101, 4); + + expect(items).toEqual([ + expect.objectContaining({ + start: new Date(rawStart.replace(' ', 'T')).toISOString(), + stop: new Date(rawStop.replace(' ', 'T')).toISOString(), + start_timestamp: '', + stop_timestamp: '', + }), + ]); + }); + + it('normalizes short and full epg items consistently for the same timestamps', async () => { + const startTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + const stopTimestamp = Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ); + + dataService.sendIpcEvent.mockImplementation( + async (_type: string, payload: unknown) => { + const action = ( + payload as { + params: { action: string }; + } + ).params.action; + + const listing = { + id: 'current', + epg_id: 'channel-101.mock', + title: Buffer.from('Current Show').toString('base64'), + description: Buffer.from('Current description').toString( + 'base64' + ), + start: '2026-04-05 03:00:00', + end: '2026-04-05 03:30:00', + start_timestamp: String(startTimestamp), + stop_timestamp: String(stopTimestamp), + channel_id: 'channel-101.mock', + }; + + if (action === 'get_short_epg') { + return { payload: { epg_listings: [listing] } }; + } + + return { + payload: { + epg_listings: [listing], + }, + }; + } + ); + + const shortItems = await service.getShortEpg(credentials, 101, 4); + const fullItems = await service.getFullEpg(credentials, 101); + + expect(shortItems[0].start).toBe(fullItems[0].start); + expect(shortItems[0].stop).toBe(fullItems[0].stop); + expect(shortItems[0].start_timestamp).toBe(fullItems[0].start_timestamp); + expect(shortItems[0].stop_timestamp).toBe(fullItems[0].stop_timestamp); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index c62225b0f..48ac17abd 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -42,12 +42,17 @@ export interface XtreamRequestOptions { * Raw EPG listing from API (before decoding) */ interface RawEpgListing { - title: string; - description: string; - start: string; - end: string; - start_timestamp: string; - stop_timestamp: string; + id?: string; + epg_id?: string; + title?: string; + description?: string; + start?: string; + end?: string; + stop?: string; + start_timestamp?: string; + stop_timestamp?: string; + channel_id?: string; + lang?: string; [key: string]: unknown; } @@ -55,7 +60,7 @@ interface RawEpgListing { * EPG API response */ interface EpgResponse { - epg_listings?: RawEpgListing[]; + epg_listings?: RawEpgListing[] | Record; } /** @@ -265,15 +270,49 @@ export class XtreamApiService { options ); - if (!response?.epg_listings || !Array.isArray(response.epg_listings)) { - return []; + return this.normalizeShortEpgItems(response); + } + + /** + * Get the full EPG schedule for a stream. + * Uses the documented endpoint with a fallback for older typoed panels. + */ + async getFullEpg( + credentials: XtreamCredentials, + streamId: number, + options?: XtreamRequestOptions + ): Promise { + try { + const response: EpgResponse = await this.sendRequest( + credentials.serverUrl, + { + action: XtreamCodeActions.GetSimpleDataTable, + username: credentials.username, + password: credentials.password, + stream_id: streamId, + }, + options + ); + const items = this.normalizeFullEpgItems(response); + if (items.length > 0) { + return items; + } + } catch { + // Fall back to the legacy typo endpoint below. } - return response.epg_listings.map((item) => ({ - ...item, - title: this.decodeBase64Unicode(item.title).trim(), - description: this.decodeBase64Unicode(item.description).trim(), - })) as EpgItem[]; + const fallbackResponse: EpgResponse = await this.sendRequest( + credentials.serverUrl, + { + action: XtreamCodeActions.GetSimpleDateTable, + username: credentials.username, + password: credentials.password, + stream_id: streamId, + }, + options + ); + + return this.normalizeFullEpgItems(fallbackResponse); } /** @@ -295,6 +334,142 @@ export class XtreamApiService { } } + private getEpgListings(response: EpgResponse | null | undefined): RawEpgListing[] { + const listings = response?.epg_listings; + if (!listings) { + return []; + } + + if (Array.isArray(listings)) { + return listings; + } + + return Object.values(listings); + } + + private normalizeShortEpgItems(response: EpgResponse): EpgItem[] { + return this.getEpgListings(response) + .map((item, index) => { + const startTimestamp = this.parseUnixTimestamp( + item.start_timestamp + ); + const stopTimestamp = this.parseUnixTimestamp( + item.stop_timestamp + ); + const normalizedStart = + this.toIsoString(startTimestamp) ?? + this.normalizeDateString(item.start); + const normalizedStop = + this.toIsoString(stopTimestamp) ?? + this.normalizeDateString(item.stop ?? item.end); + + return { + id: String(item.id ?? index), + epg_id: String(item.epg_id ?? ''), + title: this.decodeBase64Unicode( + String(item.title ?? '') + ).trim(), + description: this.decodeBase64Unicode( + String(item.description ?? '') + ).trim(), + lang: String(item.lang ?? ''), + start: normalizedStart, + end: normalizedStop, + stop: normalizedStop, + channel_id: String(item.channel_id ?? ''), + start_timestamp: String(startTimestamp ?? ''), + stop_timestamp: String(stopTimestamp ?? ''), + } satisfies EpgItem; + }) + .filter((item) => Boolean(item.start) && Boolean(item.stop)) + .sort( + (left, right) => + this.getEpgItemTimestampMs(left.start, left.start_timestamp) - + this.getEpgItemTimestampMs( + right.start, + right.start_timestamp + ) + ); + } + + private normalizeFullEpgItems(response: EpgResponse): EpgItem[] { + return this.getEpgListings(response) + .map((item, index) => { + const startTimestamp = this.parseUnixTimestamp( + item.start_timestamp + ); + const stopTimestamp = this.parseUnixTimestamp( + item.stop_timestamp + ); + const normalizedStart = + this.toIsoString(startTimestamp) ?? + this.normalizeDateString(item.start); + const normalizedStop = + this.toIsoString(stopTimestamp) ?? + this.normalizeDateString(item.stop ?? item.end); + + return { + id: String(item.id ?? index), + epg_id: String(item.epg_id ?? item.channel_id ?? ''), + title: this.decodeBase64Unicode( + String(item.title ?? '') + ).trim(), + description: this.decodeBase64Unicode( + String(item.description ?? '') + ).trim(), + lang: String(item.lang ?? ''), + start: normalizedStart, + end: normalizedStop, + stop: normalizedStop, + channel_id: String(item.channel_id ?? ''), + start_timestamp: String(startTimestamp ?? ''), + stop_timestamp: String(stopTimestamp ?? ''), + } satisfies EpgItem; + }) + .filter((item) => Boolean(item.start) && Boolean(item.stop)) + .sort( + (left, right) => + this.getEpgItemTimestampMs(left.start, left.start_timestamp) - + this.getEpgItemTimestampMs( + right.start, + right.start_timestamp + ) + ); + } + + private parseUnixTimestamp(value: unknown): number | null { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + private toIsoString(timestamp: number | null): string | null { + return timestamp ? new Date(timestamp * 1000).toISOString() : null; + } + + private normalizeDateString(value: unknown): string { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return ''; + } + + const parsed = Date.parse(rawValue.replace(' ', 'T')); + return Number.isFinite(parsed) + ? new Date(parsed).toISOString() + : rawValue; + } + + private getEpgItemTimestampMs( + isoValue: string, + unixTimestampValue: string + ): number { + const unixTimestamp = this.parseUnixTimestamp(unixTimestampValue); + if (unixTimestamp) { + return unixTimestamp * 1000; + } + + return Date.parse(isoValue); + } + /** * Send request via IPC to avoid CORS issues */ diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts new file mode 100644 index 000000000..0fc4da7a5 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -0,0 +1,79 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { DatabaseService, SettingsStore } from 'services'; +import { XtreamCredentials } from './xtream-api.service'; +import { XtreamUrlService } from './xtream-url.service'; + +describe('XtreamUrlService', () => { + let service: XtreamUrlService; + let databaseService: { + getAppState: jest.Mock, [string]>; + setAppState: jest.Mock, [string, string]>; + }; + + const credentials: XtreamCredentials = { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }; + const originalElectron = window.electron; + + beforeEach(() => { + databaseService = { + getAppState: jest.fn().mockResolvedValue(null), + setAppState: jest.fn().mockResolvedValue(undefined), + }; + + TestBed.configureTestingModule({ + providers: [ + XtreamUrlService, + { provide: DatabaseService, useValue: databaseService }, + { + provide: SettingsStore, + useValue: { + streamFormat: signal('ts'), + }, + }, + ], + }); + + service = TestBed.inject(XtreamUrlService); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + it('detects the legacy catchup scheme once and then uses the cached result', async () => { + const xtreamProbeUrl = jest + .fn() + .mockResolvedValueOnce({ status: 404 }) + .mockResolvedValueOnce({ status: 302 }); + window.electron = { + xtreamProbeUrl, + } as typeof window.electron; + + const firstUrl = await service.resolveCatchupUrl( + 'playlist-1', + credentials, + 101, + 1775296800, + 1775300400 + ); + const secondUrl = await service.resolveCatchupUrl( + 'playlist-1', + credentials, + 101, + 1775296800, + 1775300400 + ); + + expect(firstUrl).toContain('/streaming/timeshift.php?'); + expect(secondUrl).toBe(firstUrl); + expect(xtreamProbeUrl).toHaveBeenCalledTimes(2); + expect(databaseService.setAppState).toHaveBeenCalledWith( + 'xtream-catchup-scheme:playlist-1', + 'legacy' + ); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index a617a8f53..22196085a 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -3,7 +3,7 @@ import { XtreamSerieEpisode, XtreamVodDetails, } from 'shared-interfaces'; -import { SettingsStore } from 'services'; +import { DatabaseService, SettingsStore } from 'services'; import { XtreamCredentials } from './xtream-api.service'; /** @@ -30,13 +30,30 @@ type XtreamVodStreamLike = XtreamVodDetails & { readonly stream_id?: number; }; +type XtreamCatchupScheme = 'rest' | 'legacy'; + +type XtreamProbeApi = { + xtreamProbeUrl?: ( + url: string, + method?: 'GET' | 'HEAD' + ) => Promise<{ status: number }>; +}; + +const XTREAM_CATCHUP_SCHEME_KEY_PREFIX = 'xtream-catchup-scheme:'; + /** * Service for constructing Xtream stream URLs. * Handles URL construction for live streams, VOD, and series episodes. */ @Injectable({ providedIn: 'root' }) export class XtreamUrlService { + private readonly databaseService = inject(DatabaseService); private readonly settingsStore = inject(SettingsStore); + private readonly catchupSchemeCache = new Map(); + private readonly catchupSchemeRequests = new Map< + string, + Promise + >(); /** * Construct live stream URL @@ -77,4 +94,166 @@ export class XtreamUrlService { ): string { return `${credentials.serverUrl}/series/${credentials.username}/${credentials.password}/${episode.id}.${episode.container_extension}`; } + + constructCatchupUrl( + credentials: XtreamCredentials, + streamId: number, + startTimestamp: number, + stopTimestamp: number, + scheme: XtreamCatchupScheme + ): string { + const durationMinutes = Math.max( + 1, + Math.round((stopTimestamp - startTimestamp) / 60) + ); + const timeString = this.formatCatchupStartTime(startTimestamp); + + if (scheme === 'legacy') { + const params = new URLSearchParams({ + username: credentials.username, + password: credentials.password, + stream: String(streamId), + start: timeString, + duration: String(durationMinutes), + }); + return `${credentials.serverUrl}/streaming/timeshift.php?${params.toString()}`; + } + + return `${credentials.serverUrl}/timeshift/${credentials.username}/${credentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`; + } + + async resolveCatchupUrl( + playlistId: string, + credentials: XtreamCredentials, + streamId: number, + startTimestamp: number, + stopTimestamp: number + ): Promise { + const scheme = await this.getCatchupScheme( + playlistId, + credentials, + streamId, + startTimestamp, + stopTimestamp + ); + + return this.constructCatchupUrl( + credentials, + streamId, + startTimestamp, + stopTimestamp, + scheme + ); + } + + private async getCatchupScheme( + playlistId: string, + credentials: XtreamCredentials, + streamId: number, + startTimestamp: number, + stopTimestamp: number + ): Promise { + const cacheKey = `${XTREAM_CATCHUP_SCHEME_KEY_PREFIX}${playlistId}`; + const cached = this.catchupSchemeCache.get(cacheKey); + if (cached) { + return cached; + } + + const persisted = await this.databaseService.getAppState(cacheKey); + if (persisted === 'rest' || persisted === 'legacy') { + this.catchupSchemeCache.set(cacheKey, persisted); + return persisted; + } + + const inFlightRequest = this.catchupSchemeRequests.get(cacheKey); + if (inFlightRequest) { + return inFlightRequest; + } + + const request = this.detectCatchupScheme( + cacheKey, + credentials, + streamId, + startTimestamp, + stopTimestamp + ).finally(() => { + this.catchupSchemeRequests.delete(cacheKey); + }); + + this.catchupSchemeRequests.set(cacheKey, request); + return request; + } + + private async detectCatchupScheme( + cacheKey: string, + credentials: XtreamCredentials, + streamId: number, + startTimestamp: number, + stopTimestamp: number + ): Promise { + const restUrl = this.constructCatchupUrl( + credentials, + streamId, + startTimestamp, + stopTimestamp, + 'rest' + ); + const legacyUrl = this.constructCatchupUrl( + credentials, + streamId, + startTimestamp, + stopTimestamp, + 'legacy' + ); + + const restStatus = await this.probeCatchupUrl(restUrl); + let detectedScheme: XtreamCatchupScheme; + + if (this.isAcceptedCatchupProbeStatus(restStatus)) { + detectedScheme = 'rest'; + } else { + const legacyStatus = await this.probeCatchupUrl(legacyUrl); + detectedScheme = this.isAcceptedCatchupProbeStatus(legacyStatus) + ? 'legacy' + : restStatus === 404 + ? 'legacy' + : 'rest'; + } + + this.catchupSchemeCache.set(cacheKey, detectedScheme); + await this.databaseService.setAppState(cacheKey, detectedScheme); + return detectedScheme; + } + + private async probeCatchupUrl(url: string): Promise { + const probeUrl = (window.electron as XtreamProbeApi | undefined) + ?.xtreamProbeUrl; + + if (typeof probeUrl !== 'function') { + return 0; + } + + try { + const result = await probeUrl(url, 'HEAD'); + return Number(result?.status ?? 0); + } catch { + return 0; + } + } + + private isAcceptedCatchupProbeStatus(status: number): boolean { + return ( + (status >= 200 && status < 400) || + status === 401 || + status === 403 || + status === 405 + ); + } + + private formatCatchupStartTime(timestamp: number): string { + const date = new Date(timestamp * 1000); + const pad = (value: number) => String(value).padStart(2, '0'); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`; + } } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index 56e5d29bc..17240c5ec 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -85,12 +85,15 @@ describe('withContent import state', () => { let dataSource: { getCategories: jest.Mock; getContent: jest.Mock; + hasCategories: jest.Mock; + hasContent: jest.Mock; restoreUserData: jest.Mock; }; let databaseService: { clearXtreamImportCache: jest.Mock; cancelOperation: jest.Mock; createOperationId: jest.Mock; + getXtreamImportStatus: jest.Mock; setXtreamImportStatus: jest.Mock; supportsDbOperationCancellation: jest.Mock; }; @@ -105,6 +108,8 @@ describe('withContent import state', () => { dataSource = { getCategories: jest.fn().mockResolvedValue([]), getContent: jest.fn(), + hasCategories: jest.fn().mockResolvedValue(true), + hasContent: jest.fn().mockResolvedValue(true), restoreUserData: jest.fn().mockResolvedValue(undefined), }; databaseService = { @@ -118,6 +123,7 @@ describe('withContent import state', () => { operationCounter += 1; return `${prefix ?? 'db-op'}-${operationCounter}`; }), + getXtreamImportStatus: jest.fn().mockResolvedValue('completed'), setXtreamImportStatus: jest.fn().mockResolvedValue(true), supportsDbOperationCancellation: jest.fn().mockReturnValue(true), }; @@ -438,6 +444,56 @@ describe('withContent import state', () => { ); }); + it('reports offline cache availability only when every import type is completed and cached', async () => { + databaseService.getXtreamImportStatus + .mockResolvedValueOnce('completed') + .mockResolvedValueOnce('completed') + .mockResolvedValueOnce('completed'); + dataSource.hasCategories + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + dataSource.hasContent + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + + await expect(store.hasUsableOfflineCache()).resolves.toBe(true); + + expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith( + 1, + PLAYLIST.id, + 'live' + ); + expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith( + 2, + PLAYLIST.id, + 'movie' + ); + expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith( + 3, + PLAYLIST.id, + 'series' + ); + }); + + it('treats partial cache as unusable for offline initialization', async () => { + databaseService.getXtreamImportStatus + .mockResolvedValueOnce('completed') + .mockResolvedValueOnce('completed') + .mockResolvedValueOnce('completed'); + dataSource.hasCategories + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + dataSource.hasContent + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + await expect(store.hasUsableOfflineCache()).resolves.toBe(false); + }); + it('ignores concurrent initializeContent calls while an import is already running', async () => { const pendingCategories = { live: createDeferred(), diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index e67448ae1..ec7a69b4b 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -20,6 +20,7 @@ import { XtreamImportStatus, } from 'services'; import { + DbCategoryType, XTREAM_DATA_SOURCE, XtreamCategoryFromDb, } from '../../data-sources/xtream-data-source.interface'; @@ -284,6 +285,49 @@ export function withContent() { }; }; + const hasCompletedOfflineCache = async ( + playlistId: string + ): Promise => { + const contentTypes: Array<{ + categoryType: DbCategoryType; + contentType: 'live' | 'movie' | 'series'; + }> = [ + { categoryType: 'live', contentType: 'live' }, + { categoryType: 'movies', contentType: 'movie' }, + { categoryType: 'series', contentType: 'series' }, + ]; + + const cacheChecks = await Promise.all( + contentTypes.map( + async ({ categoryType, contentType }) => { + const [ + importStatus, + hasCategories, + hasContent, + ] = await Promise.all([ + databaseService.getXtreamImportStatus( + playlistId, + contentType + ), + dataSource.hasCategories( + playlistId, + categoryType + ), + dataSource.hasContent(playlistId, contentType), + ]); + + return ( + importStatus === 'completed' && + hasCategories && + hasContent + ); + } + ) + ); + + return cacheChecks.every(Boolean); + }; + const trackImportEvent = (event: DbOperationEvent): void => { const operationId = event.operationId; @@ -793,6 +837,15 @@ export function withContent() { await runContentInitialization(); }, + async hasUsableOfflineCache(): Promise { + const ctx = getCredentialsFromStore(); + if (!ctx) { + return false; + } + + return hasCompletedOfflineCache(ctx.playlistId); + }, + async retryContentInitialization(): Promise { const portalStatus = (await getPortalStore().checkPortalStatus?.()) ?? diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts new file mode 100644 index 000000000..5b08585e0 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts @@ -0,0 +1,109 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore, withState } from '@ngrx/signals'; +import { DataService } from 'services'; +import { EpgItem } from 'shared-interfaces'; +import { XtreamApiService } from '../../services/xtream-api.service'; +import { withEpg } from './with-epg.feature'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const PLAYLIST = { + id: 'playlist-1', + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', +}; + +const TestEpgStore = signalStore( + withState({ + currentPlaylist: PLAYLIST, + selectedItem: { xtream_id: 101 }, + }), + withEpg() +); + +function buildProgram( + title: string, + startTimestamp: number, + stopTimestamp: number +): EpgItem { + return { + id: title, + epg_id: title, + title, + description: `${title} description`, + lang: 'en', + start: new Date(startTimestamp * 1000).toISOString(), + stop: new Date(stopTimestamp * 1000).toISOString(), + end: new Date(stopTimestamp * 1000).toISOString(), + channel_id: 'channel-101', + start_timestamp: String(startTimestamp), + stop_timestamp: String(stopTimestamp), + }; +} + +describe('withEpg', () => { + let store: InstanceType; + let xtreamApiService: { + getFullEpg: jest.Mock, unknown[]>; + getShortEpg: jest.Mock, unknown[]>; + }; + + beforeEach(() => { + xtreamApiService = { + getFullEpg: jest.fn(), + getShortEpg: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + TestEpgStore, + { + provide: DataService, + useValue: { isElectron: true }, + }, + { + provide: XtreamApiService, + useValue: xtreamApiService, + }, + ], + }); + + store = TestBed.inject(TestEpgStore); + }); + + it('loads the full electron epg and derives the current program from timestamps', async () => { + const now = Math.floor(Date.now() / 1000); + const programs = [ + buildProgram('Past Show', now - 7200, now - 3600), + buildProgram('Current Show', now - 300, now + 1800), + buildProgram('Next Show', now + 1800, now + 3600), + ]; + xtreamApiService.getFullEpg.mockResolvedValue(programs); + + const result = await store.loadEpg(); + + expect(xtreamApiService.getFullEpg).toHaveBeenCalledWith( + { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }, + 101, + { + suppressErrorLog: true, + } + ); + expect(result).toEqual(programs); + expect(store.epgItems()).toEqual(programs); + expect(store.currentEpgItem()).toEqual(programs[1]); + expect(store.isLoadingEpg()).toBe(false); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index f7afcf6c3..a4adcfc2c 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -1,11 +1,13 @@ -import { inject } from '@angular/core'; +import { computed, inject } from '@angular/core'; import { patchState, signalStoreFeature, + withComputed, withMethods, withState, } from '@ngrx/signals'; import { EpgItem } from 'shared-interfaces'; +import { DataService } from 'services'; import { XtreamApiService, XtreamCredentials, @@ -49,9 +51,40 @@ export function withEpg() { return signalStoreFeature( withState(initialEpgState), + withComputed((store) => ({ + currentEpgItem: computed(() => { + const now = Date.now(); + const items = [...store.epgItems()].sort( + (left, right) => + getEpgTimestampMs( + left.start, + left.start_timestamp + ) - + getEpgTimestampMs( + right.start, + right.start_timestamp + ) + ); + + return ( + items.find((item) => { + const start = getEpgTimestampMs( + item.start, + item.start_timestamp + ); + const stop = getEpgTimestampMs( + item.stop ?? item.end, + item.stop_timestamp + ); + return now >= start && now < stop; + }) ?? null + ); + }), + })), withMethods((store) => { const apiService = inject(XtreamApiService); + const dataService = inject(DataService); /** * Helper to get credentials from parent store @@ -91,17 +124,25 @@ export function withEpg() { return []; } - patchState(store, { isLoadingEpg: true }); + patchState(store, { epgItems: [], isLoadingEpg: true }); try { - const epgItems = await apiService.getShortEpg( - credentials, - selectedItem.xtream_id, - 10, - { - suppressErrorLog: true, - } - ); + const epgItems = dataService.isElectron + ? await apiService.getFullEpg( + credentials, + selectedItem.xtream_id, + { + suppressErrorLog: true, + } + ) + : await apiService.getShortEpg( + credentials, + selectedItem.xtream_id, + 10, + { + suppressErrorLog: true, + } + ); patchState(store, { epgItems, @@ -153,3 +194,15 @@ export function withEpg() { }) ); } + +function getEpgTimestampMs( + dateValue: string | undefined, + unixTimestampValue: string | undefined +): number { + const unixTimestamp = Number.parseInt(String(unixTimestampValue ?? ''), 10); + if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { + return unixTimestamp * 1000; + } + + return Date.parse(String(dateValue ?? '')); +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index f62362be5..3ff704f0a 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -179,6 +179,7 @@ export function withSelection() { // Memoized sorted content - only recalculates when content/type changes const sortedContent = computed(() => { const categoryType = store.selectedContentType(); + const sortMode = store.contentSortMode(); const storeAny = store as ParentSelectionStoreLike; const content = categoryType === 'live' @@ -187,6 +188,10 @@ export function withSelection() { ? storeAny.vodStreams?.() || [] : storeAny.serialStreams?.() || []; + if (categoryType === 'vod' || categoryType === 'series') { + return sortByMode(content, sortMode, categoryType); + } + return sortByMode(content, 'date-desc', categoryType); }); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 6ab8a57e5..60b6106f0 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -1,4 +1,6 @@ @let isEmbeddedPlayer = usesEmbeddedPlayer(); +@let streamUrl = activeStreamUrl(); +@let epgPrograms = controlledEpgPrograms(); @if (selectedCategoryId()) {
- @if (epgItems().length > 0) { + @if (isLoadingEpg()) { +
+ +
+ } @else if (isElectron && epgPrograms.length > 0) { + @if (showArchiveUnavailableNotice()) { +
+
+ {{ + 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' + | translate + }} +
+
+ {{ + 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION' + | translate + }} +
+
+ } + + } @else if (!isElectron && epgItems().length > 0) { + } @else { + }
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss index 3a3bd7b79..6cddd46bf 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -18,3 +18,35 @@ width: 100%; } } + +.loading-indicator { + display: flex; + justify-content: center; + padding: 16px; +} + +.archive-unavailable-banner { + margin: 12px 12px 0; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)), + rgba(127, 127, 127, 0.05); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 10px 20px -20px rgba(0, 0, 0, 0.35); +} + +.archive-unavailable-banner__title { + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.archive-unavailable-banner__description { + margin-top: 4px; + font-size: 0.78rem; + line-height: 1.45; + opacity: 0.74; +} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts new file mode 100644 index 000000000..78e34caf7 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -0,0 +1,399 @@ +import { Directive, Component, input, output, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { MockPipe } from 'ng-mocks'; +import { TranslatePipe } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { PORTAL_PLAYER, ResizableDirective } from '@iptvnator/portal/shared/util'; +import { + FavoritesService, + XtreamStore, + XtreamUrlService, +} from '@iptvnator/portal/xtream/data-access'; +import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; +import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; +import { EpgItem, EpgProgram } from 'shared-interfaces'; +import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; +import { LiveStreamLayoutComponent } from './live-stream-layout.component'; + +@Component({ + selector: 'app-portal-channels-list', + standalone: true, + template: '', +}) +class StubPortalChannelsListComponent { + readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server'); + readonly searchTermInput = input(''); + readonly playClicked = output(); +} + +@Component({ + selector: 'app-web-player-view', + standalone: true, + template: '', +}) +class StubWebPlayerViewComponent { + readonly streamUrl = input(''); +} + +@Component({ + selector: 'app-epg-view', + standalone: true, + template: '', +}) +class StubEpgViewComponent { + readonly epgItems = input([]); +} + +@Component({ + selector: 'app-epg-list', + standalone: true, + template: '', +}) +class StubEpgListComponent { + readonly controlledPrograms = input(null); + readonly controlledArchiveDays = input(null); + readonly programActivated = output(); +} + +@Directive({ + selector: '[appResizable]', + standalone: true, +}) +class StubResizableDirective {} + +describe('LiveStreamLayoutComponent', () => { + let fixture: ComponentFixture; + let component: LiveStreamLayoutComponent; + const fixedNow = new Date('2026-04-05T12:00:00.000Z'); + + const sampleChannel = { + xtream_id: 101, + name: 'Channel 101', + stream_icon: 'channel-101.png', + tv_archive: 1, + tv_archive_duration: 3, + }; + const playlist = { + id: 'playlist-1', + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }; + + const categories = signal([{ category_id: 1, category_name: 'News' }]); + const categoryItemCounts = signal(new Map([[1, 1]])); + const epgItems = signal([]); + const currentEpgItem = signal(null); + const isLoadingEpg = signal(false); + const selectedCategoryId = signal(1); + const selectedContentType = signal<'live' | 'vod' | 'series'>('live'); + const selectedItem = signal(sampleChannel); + const currentPlaylist = signal(playlist); + + const xtreamStore = { + getCategoriesBySelectedType: categories, + getCategoryItemCounts: categoryItemCounts, + epgItems, + currentEpgItem, + isLoadingEpg, + selectedCategoryId, + selectedContentType, + selectedItem, + currentPlaylist, + selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]), + constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'), + openPlayer: jest.fn(), + }; + const favoritesService = { + getFavorites: jest.fn().mockReturnValue(of([])), + }; + const xtreamUrlService = { + resolveCatchupUrl: jest.fn().mockResolvedValue( + 'https://example.com/timeshift.ts' + ), + }; + const portalPlayer = { + isEmbeddedPlayer: jest.fn().mockReturnValue(true), + }; + + const originalElectron = window.electron; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + window.electron = { + updateRemoteControlStatus: jest.fn(), + } as typeof window.electron; + + xtreamStore.constructStreamUrl.mockClear(); + xtreamStore.openPlayer.mockClear(); + xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([ + sampleChannel, + ]); + favoritesService.getFavorites.mockClear(); + xtreamUrlService.resolveCatchupUrl.mockClear(); + portalPlayer.isEmbeddedPlayer.mockReset(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + + epgItems.set([]); + currentEpgItem.set(null); + isLoadingEpg.set(false); + selectedCategoryId.set(1); + selectedContentType.set('live'); + selectedItem.set(sampleChannel); + currentPlaylist.set(playlist); + + await TestBed.configureTestingModule({ + imports: [LiveStreamLayoutComponent, NoopAnimationsModule], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: {}, + queryParamMap: convertToParamMap({}), + }, + queryParamMap: of(convertToParamMap({})), + pathFromRoot: [ + { + snapshot: { + data: {}, + }, + }, + ], + }, + }, + { provide: XtreamStore, useValue: xtreamStore }, + { provide: FavoritesService, useValue: favoritesService }, + { provide: XtreamUrlService, useValue: xtreamUrlService }, + { provide: PORTAL_PLAYER, useValue: portalPlayer }, + ], + }) + .overrideComponent(LiveStreamLayoutComponent, { + remove: { + imports: [ + EpgListComponent, + EpgViewComponent, + PortalChannelsListComponent, + ResizableDirective, + TranslatePipe, + WebPlayerViewComponent, + ], + }, + add: { + imports: [ + StubEpgListComponent, + StubEpgViewComponent, + StubPortalChannelsListComponent, + StubResizableDirective, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + StubWebPlayerViewComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(LiveStreamLayoutComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + fixture.destroy(); + jest.useRealTimers(); + window.electron = originalElectron; + }); + + it('renders the controlled epg list for electron playback', () => { + epgItems.set([ + buildEpgItem( + '1', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-epg-list') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-view') + ).toBeNull(); + }); + + it('uses currentEpgItem instead of assuming the first schedule item is current', () => { + epgItems.set([ + buildEpgItem( + 'past', + 'Past Show', + '2026-04-05T08:00:00.000Z', + '2026-04-05T09:00:00.000Z' + ), + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + currentEpgItem.set(epgItems()[1]); + + fixture.detectChanges(); + + expect(window.electron?.updateRemoteControlStatus).toHaveBeenCalledWith( + expect.objectContaining({ + epgTitle: 'Current Show', + }) + ); + }); + + it('resolves a catchup url for archived program activation', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + await component.onProgramActivated({ + type: 'timeshift', + program: { + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + channel: 'channel-101', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1775296800, + stopTimestamp: 1775300400, + }, + }); + + expect(xtreamUrlService.resolveCatchupUrl).toHaveBeenCalledWith( + 'playlist-1', + { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }, + 101, + 1775296800, + 1775300400 + ); + expect(xtreamStore.openPlayer).toHaveBeenCalledWith( + 'https://example.com/timeshift.ts', + 'Channel 101 - Archived Show', + 'channel-101.png' + ); + }); + + it('shows an archive-unavailable notice when past programs exist but archive playback is unavailable', () => { + const nonArchiveChannel = { + ...sampleChannel, + tv_archive: 0, + tv_archive_duration: null, + }; + selectedItem.set(nonArchiveChannel); + epgItems.set([ + buildEpgItem( + 'past', + 'Past Show', + '2026-04-05T09:00:00.000Z', + '2026-04-05T10:00:00.000Z' + ), + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + + component.playLive(nonArchiveChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.archive-unavailable-banner') + ).not.toBeNull(); + }); + + it('hides the archive-unavailable notice when archive playback is available', () => { + selectedItem.set(sampleChannel); + epgItems.set([ + buildEpgItem( + 'past', + 'Past Show', + '2026-04-05T09:00:00.000Z', + '2026-04-05T10:00:00.000Z' + ), + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.archive-unavailable-banner') + ).toBeNull(); + }); + + it('hides the archive-unavailable notice when there are no past programs yet', () => { + const nonArchiveChannel = { + ...sampleChannel, + tv_archive: 0, + tv_archive_duration: null, + }; + selectedItem.set(nonArchiveChannel); + epgItems.set([ + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + buildEpgItem( + 'future', + 'Future Show', + '2026-04-05T12:30:00.000Z', + '2026-04-05T13:30:00.000Z' + ), + ]); + + component.playLive(nonArchiveChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.archive-unavailable-banner') + ).toBeNull(); + }); +}); + +function buildEpgItem( + id: string, + title: string, + start: string, + stop: string +): EpgItem { + return { + id, + epg_id: `epg-${id}`, + title, + description: '', + lang: 'en', + start, + stop, + end: stop, + channel_id: 'channel-101', + start_timestamp: String(Math.floor(Date.parse(start) / 1000)), + stop_timestamp: String(Math.floor(Date.parse(stop) / 1000)), + }; +} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index d2c8d95ba..95a03b2c1 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -25,10 +25,16 @@ import { import { FavoriteItem, FavoritesService, + XtreamUrlService, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { + EpgListComponent, + EpgProgramActivationEvent, +} from '@iptvnator/ui/epg'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; -import { EpgItem } from 'shared-interfaces'; +import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute } from '@angular/router'; @@ -40,6 +46,8 @@ interface XtreamLiveChannelItem { readonly poster_url?: string; readonly stream_icon?: string; readonly title?: string; + readonly tv_archive?: number | null; + readonly tv_archive_duration?: number | string | null; readonly xtream_id: number; } @@ -48,10 +56,12 @@ interface XtreamLiveChannelItem { templateUrl: './live-stream-layout.component.html', styleUrls: ['./live-stream-layout.component.scss'], imports: [ + EpgListComponent, EpgViewComponent, MatIcon, MatIconButton, MatMenuModule, + MatProgressSpinnerModule, MatTooltipModule, PortalChannelsListComponent, PortalEmptyStateComponent, @@ -65,13 +75,17 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); + private readonly xtreamUrlService = inject(XtreamUrlService); private readonly portalPlayer = inject(PORTAL_PLAYER); readonly categories = this.xtreamStore.getCategoriesBySelectedType; readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts; readonly epgItems = this.xtreamStore.epgItems; + readonly currentEpgItem = this.xtreamStore.currentEpgItem; + readonly isLoadingEpg = this.xtreamStore.isLoadingEpg; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; readonly liveChannelSortMode = signal('server'); + readonly isElectron = Boolean(window.electron); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); private readonly routeSearchTerm = queryParamSignal( this.route, @@ -82,6 +96,52 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); private readonly pendingAutoOpenLiveItemId = signal(null); + readonly selectedLiveItem = computed(() => { + if (this.xtreamStore.selectedContentType() !== 'live') { + return null; + } + + const selectedItem = this.xtreamStore.selectedItem(); + if (!selectedItem || typeof selectedItem !== 'object') { + return null; + } + + const item = selectedItem as XtreamLiveChannelItem; + return item.xtream_id ? item : null; + }); + readonly controlledEpgPrograms = computed(() => + this.epgItems().map((program) => this.toControlledEpgProgram(program)) + ); + private readonly currentTimeMs = signal(Date.now()); + readonly controlledArchiveDays = computed(() => + Math.max( + 0, + Number(this.selectedLiveItem()?.tv_archive_duration ?? 0) || 0 + ) + ); + readonly archivePlaybackAvailable = computed(() => { + const selectedItem = this.selectedLiveItem(); + return ( + Number(selectedItem?.tv_archive ?? 0) === 1 && + this.controlledArchiveDays() > 0 + ); + }); + readonly hasPastPrograms = computed(() => { + const now = this.currentTimeMs(); + return this.controlledEpgPrograms().some((program) => { + const stop = this.getProgramTimestampMilliseconds( + program.stop, + program.stopTimestamp + ); + return stop !== null && stop < now; + }); + }); + readonly showArchiveUnavailableNotice = computed( + () => + this.controlledEpgPrograms().length > 0 && + this.hasPastPrograms() && + !this.archivePlaybackAvailable() + ); readonly liveChannelSortLabel = computed(() => { const mode = this.liveChannelSortMode(); if (mode === 'name-asc') return 'Name A-Z'; @@ -111,10 +171,18 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly usesEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() ); - streamUrl = ''; + readonly activeStreamUrl = signal(''); favorites = new Map(); constructor() { + effect((onCleanup) => { + const intervalId = window.setInterval(() => { + this.currentTimeMs.set(Date.now()); + }, 30_000); + + onCleanup(() => clearInterval(intervalId)); + }); + const requestedItemId = Number( (window.history.state as Record | null)?.[ 'openXtreamLiveItemId' @@ -156,7 +224,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const selectedContentType = this.xtreamStore.selectedContentType(); const selectedItem = this.xtreamStore.selectedItem(); const channels = this.getVisibleChannels(); - const epgItems = this.xtreamStore.epgItems(); + const currentProgram = this.currentEpgItem(); if (selectedContentType !== 'live' || !selectedItem?.xtream_id) { window.electron.updateRemoteControlStatus({ @@ -171,7 +239,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { (item) => Number(item.xtream_id) === Number(selectedItem.xtream_id) ); - const currentProgram: EpgItem | undefined = epgItems?.[0]; window.electron.updateRemoteControlStatus({ portal: 'xtream', @@ -234,7 +301,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { playLive(item: XtreamLiveChannelItem) { const streamUrl = this.xtreamStore.constructStreamUrl(item); - this.streamUrl = streamUrl; + this.activeStreamUrl.set(streamUrl); if (this.usesEmbeddedPlayer()) { return; } @@ -245,6 +312,22 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { ); } + async onProgramActivated( + event: EpgProgramActivationEvent + ): Promise { + const selectedItem = this.selectedLiveItem(); + if (!selectedItem?.xtream_id) { + return; + } + + if (event.type === 'live') { + this.playLive(selectedItem); + return; + } + + await this.playCatchup(event.program, selectedItem); + } + setLiveChannelSortMode(mode: LiveChannelSortMode): void { this.liveChannelSortMode.set(mode); localStorage.setItem(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); @@ -302,6 +385,112 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { XtreamLiveChannelItem[]; } + private async playCatchup( + program: EpgProgram, + item: XtreamLiveChannelItem + ): Promise { + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } + + const startTimestamp = this.getProgramTimestampSeconds( + program.start, + program.startTimestamp + ); + const stopTimestamp = this.getProgramTimestampSeconds( + program.stop, + program.stopTimestamp + ); + + if (!startTimestamp || !stopTimestamp) { + return; + } + + const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl( + playlist.id, + { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }, + item.xtream_id, + startTimestamp, + stopTimestamp + ); + + this.activeStreamUrl.set(catchupUrl); + if (this.usesEmbeddedPlayer()) { + return; + } + + this.xtreamStore.openPlayer( + catchupUrl, + this.getCatchupPlaybackTitle(item, program), + item.poster_url ?? item.stream_icon ?? null + ); + } + + private toControlledEpgProgram(program: EpgItem): EpgProgram { + return { + start: program.start, + stop: program.stop ?? program.end, + channel: program.channel_id ?? program.id, + title: program.title, + desc: program.description ?? null, + category: null, + startTimestamp: this.getProgramTimestampSeconds( + program.start, + program.start_timestamp + ), + stopTimestamp: this.getProgramTimestampSeconds( + program.stop ?? program.end, + program.stop_timestamp + ), + }; + } + + private getProgramTimestampSeconds( + dateValue: string, + unixTimestampValue?: number | string | null + ): number | null { + const unixTimestamp = Number.parseInt( + String(unixTimestampValue ?? ''), + 10 + ); + if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { + return unixTimestamp; + } + + const parsedDate = Date.parse(dateValue); + return Number.isFinite(parsedDate) + ? Math.floor(parsedDate / 1000) + : null; + } + + private getProgramTimestampMilliseconds( + dateValue: string, + unixTimestampValue?: number | string | null + ): number | null { + const unixTimestamp = this.getProgramTimestampSeconds( + dateValue, + unixTimestampValue + ); + return unixTimestamp !== null ? unixTimestamp * 1000 : null; + } + + private getCatchupPlaybackTitle( + item: XtreamLiveChannelItem, + program: EpgProgram + ): string { + const channelTitle = item.title ?? item.name ?? ''; + if (!program.title) { + return channelTitle; + } + + return channelTitle ? `${channelTitle} - ${program.title}` : program.title; + } + private clearAutoOpenHistoryState(): void { try { const state = (window.history.state ?? {}) as Record< diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 04f80b1cc..91ebc1d7f 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -11,6 +11,29 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { PortalChannelsListComponent } from './portal-channels-list.component'; +function buildEpgItem(params: { + id: string; + title: string; + start: string; + stop: string; + startTimestamp: number; + stopTimestamp: number; +}) { + return { + id: params.id, + epg_id: `epg-${params.id}`, + title: params.title, + description: `${params.title} description`, + lang: 'en', + start: params.start, + end: params.stop, + stop: params.stop, + channel_id: 'channel-1', + start_timestamp: String(params.startTimestamp), + stop_timestamp: String(params.stopTimestamp), + }; +} + describe('PortalChannelsListComponent', () => { let fixture: ComponentFixture; const selectedChannels = signal([]); @@ -103,6 +126,10 @@ describe('PortalChannelsListComponent', () => { fixture = TestBed.createComponent(PortalChannelsListComponent); }); + afterEach(() => { + jest.useRealTimers(); + }); + it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => { fixture.detectChanges(); @@ -126,4 +153,72 @@ describe('PortalChannelsListComponent', () => { fixture.nativeElement.querySelector('.empty-search-state') ).not.toBeNull(); }); + + it('selects the current preview program by timestamps, preserves them, and updates progress from them', () => { + jest.useFakeTimers(); + const currentStartTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + const currentStopTimestamp = Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ); + const previousStartTimestamp = Math.floor( + Date.parse('2026-04-05T05:00:00.000Z') / 1000 + ); + const previousStopTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + + jest.setSystemTime(new Date('2026-04-05T05:45:00.000Z')); + + selectedTypeContentLoading.set(false); + selectedChannels.set([ + { + title: 'Cartoon Network', + xtream_id: 50, + }, + ]); + currentPlaylist.set({ + id: 'playlist-1', + password: 'secret', + serverUrl: 'http://demo.example', + username: 'demo', + }); + + fixture.detectChanges(); + + epgResults$.next({ + streamId: 50, + items: [ + buildEpgItem({ + id: 'previous', + title: 'Previous Show', + start: '2026-04-05T03:00:00.000Z', + stop: '2026-04-05T03:30:00.000Z', + startTimestamp: previousStartTimestamp, + stopTimestamp: previousStopTimestamp, + }), + buildEpgItem({ + id: 'current', + title: 'Current Show', + start: '2026-04-05T03:00:00.000Z', + stop: '2026-04-05T03:30:00.000Z', + startTimestamp: currentStartTimestamp, + stopTimestamp: currentStopTimestamp, + }), + ], + }); + + fixture.detectChanges(); + + const component = fixture.componentInstance; + expect(component.epgPrograms.get(50)).toEqual( + expect.objectContaining({ + title: 'Current Show', + startTimestamp: currentStartTimestamp, + stopTimestamp: currentStopTimestamp, + }) + ); + expect(component.currentProgramsProgress.get(50)).toBeCloseTo(50, 1); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index e3386a1af..c028b79a8 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -349,6 +349,29 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { title: program.title, desc: program.description ?? null, category: null, + startTimestamp: this.getProgramTimestampSeconds( + program.start, + program.start_timestamp + ), + stopTimestamp: this.getProgramTimestampSeconds( + program.stop ?? program.end, + program.stop_timestamp + ), }; } + + private getProgramTimestampSeconds( + dateValue: string | undefined, + unixTimestampValue: string | undefined + ): number | null { + const unixTimestamp = Number(unixTimestampValue); + if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { + return unixTimestamp; + } + + const parsedDate = new Date(dateValue ?? '').getTime(); + return Number.isFinite(parsedDate) + ? Math.floor(parsedDate / 1000) + : null; + } } diff --git a/libs/portal/xtream/feature/src/lib/xtream-cached-offline-notice.component.ts b/libs/portal/xtream/feature/src/lib/xtream-cached-offline-notice.component.ts new file mode 100644 index 000000000..10b577a1d --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-cached-offline-notice.component.ts @@ -0,0 +1,81 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; + +@Component({ + selector: 'app-xtream-cached-offline-notice', + imports: [MatIconModule, TranslatePipe], + template: ` + @if (showWarning()) { +
+ cloud_off + +
+ + {{ + 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' + | translate + }} + + + {{ + 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION' + | translate + }} + +
+
+ } + `, + styles: [ + ` + .xtream-cached-offline-notice { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 0 12px; + padding: 12px 14px; + border-radius: 12px; + background: var(--mat-sys-error-container); + color: var(--mat-sys-on-error-container); + } + + .xtream-cached-offline-notice__copy { + display: grid; + gap: 4px; + } + + .xtream-cached-offline-notice__copy strong { + font-size: 0.92rem; + font-weight: 700; + line-height: 1.2; + } + + .xtream-cached-offline-notice__copy span { + font-size: 0.84rem; + line-height: 1.35; + } + + .xtream-cached-offline-notice mat-icon { + margin-top: 1px; + flex-shrink: 0; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class XtreamCachedOfflineNoticeComponent { + private readonly xtreamStore = inject(XtreamStore); + + readonly showWarning = computed( + () => + this.xtreamStore.portalStatus() === 'unavailable' && + this.xtreamStore.isContentInitialized() && + !this.xtreamStore.contentInitBlockReason() + ); +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts new file mode 100644 index 000000000..5492aaba8 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts @@ -0,0 +1,193 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + PortalCatalogSortMode, +} from '@iptvnator/portal/shared/util'; +import { + XtreamPlaylistData, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { XtreamCatalogFacadeService } from './xtream-catalog-facade.service'; + +const PLAYLIST_ONE: XtreamPlaylistData = { + id: 'playlist-1', + name: 'Playlist One', + title: 'Playlist One', + serverUrl: 'http://localhost:3000', + username: 'user', + password: 'secret', + type: 'xtream', +}; + +const PLAYLIST_TWO: XtreamPlaylistData = { + ...PLAYLIST_ONE, + id: 'playlist-2', + name: 'Playlist Two', + title: 'Playlist Two', +}; + +describe('XtreamCatalogFacadeService', () => { + let service: XtreamCatalogFacadeService; + const contentType = signal<'live' | 'vod' | 'series'>('vod'); + const limit = signal(25); + const page = signal(0); + const selectedCategory = signal | null>({ + id: 11, + name: 'Movies', + }); + const selectedCategoryId = signal(11); + const paginatedContent = signal[]>([ + { xtream_id: 1, title: 'A' }, + ]); + const selectedCategoryItems = signal[]>([ + { xtream_id: 1, title: 'A' }, + { xtream_id: 2, title: 'B' }, + ]); + const selectedItem = signal | null>(null); + const totalPages = signal(1); + const isPaginatedContentLoading = signal(false); + const contentSortMode = signal('date-desc'); + const currentPlaylist = signal(PLAYLIST_ONE); + + const xtreamStore = { + selectedContentType: contentType, + limit, + page, + getSelectedCategory: selectedCategory, + selectedCategoryId, + getPaginatedContent: paginatedContent, + selectItemsFromSelectedCategory: selectedCategoryItems, + selectedItem, + getTotalPages: totalPages, + isPaginatedContentLoading, + contentSortMode, + currentPlaylist, + loadAllPositions: jest.fn(), + setCategorySearchTerm: jest.fn(), + setSelectedItem: jest.fn((item: Record | null) => { + selectedItem.set(item); + }), + setSelectedCategory: jest.fn((categoryId: number | null) => { + selectedCategoryId.set(categoryId); + }), + setPage: jest.fn((nextPage: number) => { + page.set(nextPage); + }), + setLimit: jest.fn((nextLimit: number) => { + limit.set(nextLimit); + }), + setContentSortMode: jest.fn((mode: PortalCatalogSortMode) => { + contentSortMode.set(mode); + }), + hasSeriesProgress: jest.fn().mockReturnValue(false), + getProgressPercent: jest.fn().mockReturnValue(40), + isWatched: jest.fn().mockReturnValue(false), + }; + + beforeEach(() => { + localStorage.removeItem('xtream-category-sort-mode'); + contentType.set('vod'); + limit.set(25); + page.set(0); + selectedCategory.set({ id: 11, name: 'Movies' }); + selectedCategoryId.set(11); + paginatedContent.set([{ xtream_id: 1, title: 'A' }]); + selectedCategoryItems.set([ + { xtream_id: 1, title: 'A' }, + { xtream_id: 2, title: 'B' }, + ]); + selectedItem.set(null); + totalPages.set(1); + isPaginatedContentLoading.set(false); + contentSortMode.set('date-desc'); + currentPlaylist.set(PLAYLIST_ONE); + + xtreamStore.loadAllPositions.mockClear(); + xtreamStore.setCategorySearchTerm.mockClear(); + xtreamStore.setSelectedItem.mockClear(); + xtreamStore.setSelectedCategory.mockClear(); + xtreamStore.setPage.mockClear(); + xtreamStore.setLimit.mockClear(); + xtreamStore.setContentSortMode.mockClear(); + xtreamStore.hasSeriesProgress.mockClear(); + xtreamStore.getProgressPercent.mockClear(); + xtreamStore.isWatched.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + XtreamCatalogFacadeService, + { + provide: XtreamStore, + useValue: xtreamStore, + }, + ], + }); + + service = TestBed.inject(XtreamCatalogFacadeService); + }); + + it('delegates category search to the Xtream store', () => { + service.setSearchQuery('matrix'); + + expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith( + 'matrix' + ); + }); + + it('exposes store-driven paginated content, total pages, and category counts', () => { + expect(service.paginatedContent()).toEqual([ + { xtream_id: 1, title: 'A' }, + ]); + expect(service.totalPages()).toBe(1); + expect(service.categoryItemCount()).toBe(2); + + paginatedContent.set([ + { xtream_id: 3, title: 'C' }, + { xtream_id: 4, title: 'D' }, + ]); + selectedCategoryItems.set([ + { xtream_id: 3, title: 'C' }, + { xtream_id: 4, title: 'D' }, + { xtream_id: 5, title: 'E' }, + ]); + totalPages.set(4); + + expect(service.paginatedContent()).toEqual([ + { xtream_id: 3, title: 'C' }, + { xtream_id: 4, title: 'D' }, + ]); + expect(service.totalPages()).toBe(4); + expect(service.categoryItemCount()).toBe(3); + }); + + it('restores saved sort mode, sets the selected category, and loads positions once per playlist', () => { + localStorage.setItem('xtream-category-sort-mode', 'name-asc'); + + service.initialize('42'); + service.initialize('77'); + + expect(xtreamStore.setContentSortMode).toHaveBeenCalledWith('name-asc'); + expect(xtreamStore.setSelectedCategory).toHaveBeenLastCalledWith(77); + expect(xtreamStore.loadAllPositions).toHaveBeenCalledTimes(1); + expect(xtreamStore.loadAllPositions).toHaveBeenCalledWith('playlist-1'); + + currentPlaylist.set(PLAYLIST_TWO); + service.initialize('88'); + + expect(xtreamStore.loadAllPositions).toHaveBeenCalledTimes(2); + expect(xtreamStore.loadAllPositions).toHaveBeenLastCalledWith( + 'playlist-2' + ); + }); + + it('persists sort mode changes and delegates them to the store', () => { + service.setContentSortMode('name-desc'); + + expect(xtreamStore.setContentSortMode).toHaveBeenCalledWith( + 'name-desc' + ); + expect(localStorage.getItem('xtream-category-sort-mode')).toBe( + 'name-desc' + ); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index 7d6e8d923..40b81b61e 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -17,6 +17,7 @@ export class XtreamCatalogFacadeService { private readonly xtreamStore = inject(XtreamStore); private savedPageBeforeDetail: number | null = null; + private loadedPositionsPlaylistId: string | null = null; readonly provider = 'xtream' as const; readonly pageSizeOptions = [10, 25, 50, 100] as const; @@ -33,8 +34,8 @@ export class XtreamCatalogFacadeService const category = this.selectedCategory(); return String(category?.['name'] ?? category?.['title'] ?? ''); }); - readonly categoryItemCount = computed( - () => this.xtreamStore.selectItemsFromSelectedCategory().length + readonly categoryItemCount = computed(() => + this.xtreamStore.selectItemsFromSelectedCategory().length ); readonly contentSortMode = this.xtreamStore.contentSortMode; readonly playlist = computed(() => { @@ -61,7 +62,8 @@ export class XtreamCatalogFacadeService } const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (playlistId) { + if (playlistId && this.loadedPositionsPlaylistId !== playlistId) { + this.loadedPositionsPlaylistId = playlistId; this.xtreamStore.loadAllPositions(playlistId); } @@ -83,6 +85,10 @@ export class XtreamCatalogFacadeService this.xtreamStore.setSelectedItem(null); } + setSearchQuery(query: string): void { + this.xtreamStore.setCategorySearchTerm(query); + } + setPage(page: number): void { this.xtreamStore.setPage(page); } diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts index 6eb9284cd..312d559f0 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts @@ -1,22 +1,38 @@ import { + inject, ChangeDetectionStrategy, Component, input, } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; +import { routeParamSignal } from '@iptvnator/portal/shared/util'; +import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-notice.component'; @Component({ selector: 'app-xtream-collection-route', - imports: [UnifiedCollectionPageComponent], + imports: [ + UnifiedCollectionPageComponent, + XtreamCachedOfflineNoticeComponent, + ], template: ` + `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class XtreamCollectionRouteComponent { + private readonly route = inject(ActivatedRoute); + readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input('xtream'); + readonly playlistId = routeParamSignal( + this.route, + 'id', + (value) => value ?? undefined + ); } diff --git a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts index ffe4cc547..30ae79211 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts @@ -9,6 +9,7 @@ import { XtreamContentInitBlockReason, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-notice.component'; import { XtreamContentGateComponent } from './xtream-content-gate.component'; @Component({ @@ -30,10 +31,16 @@ describe('XtreamContentGateComponent', () => { let fixture: ComponentFixture; const contentInitBlockReason = signal(null); + const isContentInitialized = signal(false); + const portalStatus = signal<'active' | 'inactive' | 'expired' | 'unavailable'>( + 'active' + ); const retryContentInitialization = jest.fn().mockResolvedValue(undefined); beforeEach(async () => { contentInitBlockReason.set(null); + isContentInitialized.set(false); + portalStatus.set('active'); retryContentInitialization.mockClear(); await TestBed.configureTestingModule({ @@ -57,6 +64,8 @@ describe('XtreamContentGateComponent', () => { provide: XtreamStore, useValue: { contentInitBlockReason, + isContentInitialized, + portalStatus, retryContentInitialization, }, }, @@ -70,6 +79,7 @@ describe('XtreamContentGateComponent', () => { MockPlaylistErrorViewComponent, RouterOutlet, TranslatePipe, + XtreamCachedOfflineNoticeComponent, ], }, }) @@ -106,6 +116,21 @@ describe('XtreamContentGateComponent', () => { expect(fixture.nativeElement.querySelector('router-outlet')).not.toBeNull(); }); + it('shows an inline warning when cached content remains available offline', () => { + isContentInitialized.set(true); + portalStatus.set('unavailable'); + fixture.detectChanges(); + + const warning = fixture.nativeElement.querySelector( + '[data-testid="xtream-offline-warning"]' + ) as HTMLElement | null; + + expect(warning?.textContent).toContain( + 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' + ); + expect(fixture.nativeElement.querySelector('router-outlet')).not.toBeNull(); + }); + it('retries content initialization from the blocked state', () => { contentInitBlockReason.set('cancelled'); fixture.detectChanges(); diff --git a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts index dd02b0509..420d545aa 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts @@ -10,6 +10,7 @@ import { XtreamContentInitBlockReason, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-notice.component'; @Component({ selector: 'app-xtream-content-gate', @@ -20,6 +21,7 @@ import { PlaylistErrorViewComponent, RouterOutlet, TranslatePipe, + XtreamCachedOfflineNoticeComponent, ], template: ` @if (contentInitBlockReason(); as blockReason) { @@ -42,6 +44,7 @@ import { } @else { + } `, diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts index 6ca8ca9a8..60d206c52 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -21,6 +21,10 @@ const ACTIVE_PLAYLIST: PlaylistMeta = { title: 'Test Xtream', username: 'user1', } as PlaylistMeta; +const UPDATED_ACTIVE_PLAYLIST: PlaylistMeta = { + ...ACTIVE_PLAYLIST, + serverUrl: 'http://localhost:65530', +} as PlaylistMeta; const XTREAM_PLAYLIST: XtreamPlaylistData = { id: PLAYLIST_ID, @@ -31,12 +35,26 @@ const XTREAM_PLAYLIST: XtreamPlaylistData = { password: 'secret', type: 'xtream', }; +const XTREAM_PLAYLIST_WITH_NULL_OPTIONALS = { + ...XTREAM_PLAYLIST, + origin: null, + referrer: null, + userAgent: null, +} as XtreamPlaylistData; async function flushEffects(): Promise { await Promise.resolve(); await Promise.resolve(); } +function getXtreamSectionFromUrl(url: string): string | null { + const match = url.match( + /^\/workspace\/xtreams\/[^/]+\/([^/?]+)(?:\/|$)/ + ); + + return match?.[1] ?? null; +} + describe('XtreamWorkspaceRouteSession', () => { const routerEvents = new Subject(); const routeProvider = signal<'xtreams' | null>('xtreams'); @@ -45,8 +63,12 @@ describe('XtreamWorkspaceRouteSession', () => { const currentPlaylist = signal(XTREAM_PLAYLIST); const playlistId = signal(PLAYLIST_ID); const portalStatus = signal('active'); + const selectedContentType = signal<'live' | 'vod' | 'series'>('vod'); + const selectedCategoryId = signal(null); + const isContentInitialized = signal(false); const contentInitBlockReason = signal(null); + let hasUsableOfflineCache = false; const playlistContext = { routeProvider, @@ -62,15 +84,30 @@ describe('XtreamWorkspaceRouteSession', () => { resetStore: jest.fn((nextPlaylistId?: string) => { playlistId.set(nextPlaylistId ?? null); currentPlaylist.set(null); + selectedContentType.set('vod'); + isContentInitialized.set(false); }), setCurrentPlaylist: jest.fn((playlist: XtreamPlaylistData | null) => { currentPlaylist.set(playlist); }), fetchXtreamPlaylist: jest.fn().mockResolvedValue(undefined), checkPortalStatus: jest.fn(), + hasUsableOfflineCache: jest.fn().mockImplementation(async () => { + return hasUsableOfflineCache; + }), + isContentInitialized, contentInitBlockReason, - initializeContent: jest.fn().mockResolvedValue(undefined), - setSelectedContentType: jest.fn(), + initializeContent: jest.fn().mockImplementation(async () => { + isContentInitialized.set(true); + }), + setSelectedContentType: jest.fn( + (type: 'live' | 'vod' | 'series') => { + selectedContentType.set(type); + } + ), + setSelectedCategory: jest.fn((categoryId: number | null) => { + selectedCategoryId.set(categoryId); + }), setContentInitBlockReason: jest.fn( (reason: XtreamContentInitBlockReason | null) => { contentInitBlockReason.set(reason); @@ -88,24 +125,37 @@ describe('XtreamWorkspaceRouteSession', () => { routeProvider.set('xtreams'); routePlaylistId.set(PLAYLIST_ID); activePlaylist.set(ACTIVE_PLAYLIST); - currentPlaylist.set(XTREAM_PLAYLIST); - playlistId.set(PLAYLIST_ID); + currentPlaylist.set(null); + playlistId.set(null); portalStatus.set('active'); + selectedContentType.set('vod'); + selectedCategoryId.set(null); + isContentInitialized.set(false); contentInitBlockReason.set(null); + hasUsableOfflineCache = false; playlistContext.syncFromUrl.mockImplementation((url: string) => ({ inWorkspace: true, provider: 'xtreams', playlistId: PLAYLIST_ID, - section: url.endsWith('/favorites') ? 'favorites' : 'vod', + section: getXtreamSectionFromUrl(url) as + | 'favorites' + | 'live' + | 'recently-added' + | 'search' + | 'series' + | 'vod' + | null, })); xtreamStore.resetStore.mockClear(); xtreamStore.setCurrentPlaylist.mockClear(); xtreamStore.fetchXtreamPlaylist.mockClear(); xtreamStore.checkPortalStatus.mockReset(); + xtreamStore.hasUsableOfflineCache.mockClear(); xtreamStore.initializeContent.mockClear(); xtreamStore.setSelectedContentType.mockClear(); + xtreamStore.setSelectedCategory.mockClear(); xtreamStore.setContentInitBlockReason.mockClear(); await TestBed.configureTestingModule({ @@ -142,6 +192,91 @@ describe('XtreamWorkspaceRouteSession', () => { expect(xtreamStore.initializeContent).toHaveBeenCalled(); }); + it('reapplies a live route section after resetStore restores the default selection', async () => { + router.url = `/workspace/xtreams/${PLAYLIST_ID}/live`; + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('active'); + return 'active'; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + + expect(xtreamStore.resetStore).toHaveBeenCalledWith(PLAYLIST_ID); + expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('live'); + expect(selectedContentType()).toBe('live'); + expect( + xtreamStore.setSelectedContentType.mock.invocationCallOrder[0] + ).toBeGreaterThan(xtreamStore.resetStore.mock.invocationCallOrder[0]); + }); + + it('does not bootstrap again when the store already holds the active playlist', async () => { + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`; + currentPlaylist.set(XTREAM_PLAYLIST); + playlistId.set(PLAYLIST_ID); + isContentInitialized.set(true); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + + expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod'); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(101); + expect(xtreamStore.resetStore).not.toHaveBeenCalled(); + expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled(); + expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled(); + expect(xtreamStore.initializeContent).not.toHaveBeenCalled(); + }); + + it('does not treat null and undefined Xtream connection metadata as a playlist change', async () => { + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`; + currentPlaylist.set(XTREAM_PLAYLIST_WITH_NULL_OPTIONALS); + playlistId.set(PLAYLIST_ID); + isContentInitialized.set(true); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + + expect(xtreamStore.resetStore).not.toHaveBeenCalled(); + expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled(); + expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled(); + expect(xtreamStore.initializeContent).not.toHaveBeenCalled(); + }); + + it('does not reinitialize content when switching categories in an initialized playlist', async () => { + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`; + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('active'); + return 'active'; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + await flushEffects(); + + expect(isContentInitialized()).toBe(true); + + xtreamStore.resetStore.mockClear(); + xtreamStore.fetchXtreamPlaylist.mockClear(); + xtreamStore.checkPortalStatus.mockClear(); + xtreamStore.initializeContent.mockClear(); + xtreamStore.setSelectedContentType.mockClear(); + xtreamStore.setSelectedCategory.mockClear(); + + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/202`; + routerEvents.next( + new NavigationEnd(1, router.url, router.url) + ); + await flushEffects(); + + expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod'); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(202); + expect(selectedCategoryId()).toBe(202); + expect(xtreamStore.resetStore).not.toHaveBeenCalled(); + expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled(); + expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled(); + expect(xtreamStore.initializeContent).not.toHaveBeenCalled(); + }); + it.each(['expired', 'inactive', 'unavailable'] as const)( 'blocks %s portals before import-driven initialization starts', async (status) => { @@ -161,6 +296,49 @@ describe('XtreamWorkspaceRouteSession', () => { } ); + it('allows unavailable portals to initialize cached content', async () => { + hasUsableOfflineCache = true; + activePlaylist.set(UPDATED_ACTIVE_PLAYLIST); + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('unavailable'); + return 'unavailable'; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + await flushEffects(); + + expect(xtreamStore.checkPortalStatus).toHaveBeenCalled(); + expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalled(); + expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith( + null + ); + expect(xtreamStore.initializeContent).toHaveBeenCalled(); + }); + + it('rebootstraps the current Xtream playlist when its connection details change', async () => { + activePlaylist.set(UPDATED_ACTIVE_PLAYLIST); + currentPlaylist.set(XTREAM_PLAYLIST); + playlistId.set(PLAYLIST_ID); + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('active'); + return 'active'; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + await flushEffects(); + + expect(xtreamStore.resetStore).toHaveBeenCalledWith(PLAYLIST_ID); + expect(xtreamStore.setCurrentPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + id: PLAYLIST_ID, + serverUrl: UPDATED_ACTIVE_PLAYLIST.serverUrl, + }) + ); + expect(xtreamStore.checkPortalStatus).toHaveBeenCalled(); + }); + it('does not clear a cancelled block while an active portal bootstrap finishes', async () => { xtreamStore.checkPortalStatus.mockImplementation(async () => { portalStatus.set('active'); 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 6efb3f4d0..329c5e8d0 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 @@ -68,6 +68,74 @@ function toContentInitBlockReason( } } +function hasPlaylistConnectionChanges( + currentPlaylist: XtreamPlaylistData | null, + nextPlaylist: XtreamPlaylistData | null +): boolean { + if (!currentPlaylist || !nextPlaylist) { + return false; + } + + const currentUserAgent = currentPlaylist.userAgent ?? null; + const nextUserAgent = nextPlaylist.userAgent ?? null; + const currentReferrer = currentPlaylist.referrer ?? null; + const nextReferrer = nextPlaylist.referrer ?? null; + const currentOrigin = currentPlaylist.origin ?? null; + const nextOrigin = nextPlaylist.origin ?? null; + + return ( + currentPlaylist.serverUrl !== nextPlaylist.serverUrl || + currentPlaylist.username !== nextPlaylist.username || + currentPlaylist.password !== nextPlaylist.password || + currentUserAgent !== nextUserAgent || + currentReferrer !== nextReferrer || + currentOrigin !== nextOrigin + ); +} + +function shouldBootstrapXtreamPlaylist( + playlistId: string | null, + routePlaylist: XtreamPlaylistData | null, + storePlaylistId: string | null, + currentPlaylist: XtreamPlaylistData | null +): boolean { + const currentPlaylistUpdateDate = currentPlaylist?.updateDate ?? null; + const routePlaylistUpdateDate = routePlaylist?.updateDate ?? null; + + return Boolean( + playlistId && + routePlaylist && + (storePlaylistId !== playlistId || + currentPlaylist?.id !== playlistId || + currentPlaylistUpdateDate !== routePlaylistUpdateDate || + hasPlaylistConnectionChanges(currentPlaylist, routePlaylist)) + ); +} + +function getXtreamRouteCategoryId( + url: string, + section: PortalRailSection | null +): number | null { + if ( + section !== 'live' && + section !== 'vod' && + section !== 'series' + ) { + return null; + } + + const path = url.split('?')[0] ?? ''; + const segments = path.split('/').filter(Boolean); + const categorySegment = segments[4]; + + if (!categorySegment) { + return null; + } + + const categoryId = Number(categorySegment); + return Number.isNaN(categoryId) ? null : categoryId; +} + @Injectable() export class XtreamWorkspaceRouteSession { private readonly destroyRef = inject(DestroyRef); @@ -75,8 +143,6 @@ export class XtreamWorkspaceRouteSession { private readonly router = inject(Router); private readonly xtreamStore = inject(XtreamStore); - private currentPlaylistId: string | null = null; - private currentPlaylistUpdateDate: number | null = null; private syncInFlight = false; private syncPending = false; @@ -138,36 +204,39 @@ export class XtreamWorkspaceRouteSession { routeContext.provider === 'xtreams' ? routeContext.playlistId : null; - const section = this.syncRouteState(routeContext.section); + const routeSection = + routeContext.provider === 'xtreams' ? routeContext.section : null; const routePlaylist = routeContext.provider === 'xtreams' ? toXtreamPlaylistData(this.playlistContext.activePlaylist()) : null; + const storePlaylistId = this.xtreamStore.playlistId(); const currentPlaylist = this.xtreamStore.currentPlaylist(); - const routePlaylistUpdateDate = routePlaylist?.updateDate ?? null; - const needsPlaylistBootstrap = Boolean( - playlistId && - routePlaylist && - (currentPlaylist?.id !== playlistId || - this.currentPlaylistUpdateDate !== routePlaylistUpdateDate) + const shouldBootstrapPlaylist = shouldBootstrapXtreamPlaylist( + playlistId, + routePlaylist, + storePlaylistId, + currentPlaylist ); let portalStatus = this.xtreamStore.portalStatus(); + let didBootstrapPlaylist = false; - if ( - playlistId && - (this.currentPlaylistId !== playlistId || needsPlaylistBootstrap) - ) { - if (this.currentPlaylistId !== playlistId || needsPlaylistBootstrap) { - this.xtreamStore.resetStore(playlistId); - this.currentPlaylistId = playlistId; - this.currentPlaylistUpdateDate = routePlaylistUpdateDate; - } + if (playlistId && shouldBootstrapPlaylist) { + this.xtreamStore.resetStore(playlistId); + didBootstrapPlaylist = true; this.xtreamStore.setCurrentPlaylist(routePlaylist); await this.xtreamStore.fetchXtreamPlaylist(); portalStatus = await this.xtreamStore.checkPortalStatus(); - const nextBlockReason = toContentInitBlockReason(portalStatus); + const hasUsableOfflineCache = + portalStatus === 'unavailable' + ? await this.xtreamStore.hasUsableOfflineCache() + : false; + const nextBlockReason = + portalStatus === 'unavailable' && hasUsableOfflineCache + ? null + : toContentInitBlockReason(portalStatus); const currentBlockReason = this.xtreamStore.contentInitBlockReason(); @@ -179,11 +248,25 @@ export class XtreamWorkspaceRouteSession { } } - if (isImportDrivenSection(section) && portalStatus !== 'active') { + const section = this.syncRouteState(routeSection); + + const canUseOfflineCache = + portalStatus === 'unavailable' + ? await this.xtreamStore.hasUsableOfflineCache() + : false; + + if ( + isImportDrivenSection(section) && + portalStatus !== 'active' && + !canUseOfflineCache + ) { return; } - await this.initializeCurrentSectionContent(section); + await this.initializeCurrentSectionContent( + section, + didBootstrapPlaylist + ); } private syncRouteState( @@ -197,11 +280,16 @@ export class XtreamWorkspaceRouteSession { this.xtreamStore.setSelectedContentType(section); } + this.xtreamStore.setSelectedCategory( + getXtreamRouteCategoryId(this.router.url, section) + ); + return section; } private async initializeCurrentSectionContent( - section: PortalRailSection | null + section: PortalRailSection | null, + didBootstrapPlaylist: boolean ): Promise { const playlist = this.xtreamStore.currentPlaylist(); const playlistId = this.xtreamStore.playlistId(); @@ -211,7 +299,8 @@ export class XtreamWorkspaceRouteSession { } if ( - isImportDrivenSection(section) + isImportDrivenSection(section) && + (didBootstrapPlaylist || !this.xtreamStore.isContentInitialized()) ) { await this.xtreamStore.initializeContent(); } diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index bf62ce1d2..684e65674 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -25,6 +25,10 @@ export interface XtreamContent { rating: string; added: string; poster_url: string; + epg_channel_id?: string | null; + tv_archive?: number | null; + tv_archive_duration?: number | null; + direct_source?: string | null; xtream_id: number; type: string; added_at?: string; @@ -60,6 +64,10 @@ type XtreamContentStream = cover?: string; name?: string; title?: string; + epg_channel_id?: string; + tv_archive?: string | number; + tv_archive_duration?: string | number; + direct_source?: string; series_id?: string | number; stream_id?: string | number; } diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 25c6dadf6..0b170007b 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -17,10 +17,43 @@ import { getIptvnatorDatabasePath } from './path-utils'; export type DatabaseInstance = BetterSQLite3Database; +const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + let db: DatabaseInstance | null = null; let sqlite: Database.Database | null = null; let initPromise: Promise | null = null; +function readTraceFlag(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase(); + return value ? TRACE_ENV_TRUE_VALUES.has(value) : false; +} + +function isSqlTraceEnabled(): boolean { + return ( + readTraceFlag('IPTVNATOR_TRACE_STARTUP') || + readTraceFlag('IPTVNATOR_TRACE_DB') || + readTraceFlag('IPTVNATOR_TRACE_SQL') + ); +} + +function compactSqlForTrace(sql: string): string { + const compactSql = sql.replace(/\s+/g, ' ').trim(); + return compactSql.length <= 180 + ? compactSql + : `${compactSql.slice(0, 177)}...`; +} + +function traceSql(scope: string, message: string, payload?: unknown): void { + if (payload === undefined) { + console.log(`[IPTVnator Trace][${scope}] ${message}`); + return; + } + + console.log( + `[IPTVnator Trace][${scope}] ${message} ${JSON.stringify(payload)}` + ); +} + /** * Get the database file path */ @@ -79,6 +112,10 @@ const CREATE_TABLE_STATEMENTS = [ rating TEXT, added TEXT, poster_url TEXT, + epg_channel_id TEXT, + tv_archive INTEGER, + tv_archive_duration INTEGER, + direct_source TEXT, xtream_id INTEGER NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')), FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE @@ -227,6 +264,11 @@ const COLUMN_MIGRATION_STATEMENTS = [ `ALTER TABLE playlists ADD COLUMN payload TEXT`, // v1.2.0 -> v1.3.0: Add position column to favorites for global favorites ordering `ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0`, + // v1.4.0 -> v1.5.0: Preserve Xtream live metadata required for EPG/catch-up + `ALTER TABLE content ADD COLUMN epg_channel_id TEXT`, + `ALTER TABLE content ADD COLUMN tv_archive INTEGER`, + `ALTER TABLE content ADD COLUMN tv_archive_duration INTEGER`, + `ALTER TABLE content ADD COLUMN direct_source TEXT`, ]; const INDEX_MIGRATION_STATEMENTS = [ @@ -458,7 +500,23 @@ export async function initDatabase( initPromise = (async () => { const filePath = getDatabasePath(); - sqlite = new Database(filePath, { readonly }); + sqlite = new Database(filePath, { + readonly, + verbose: isSqlTraceEnabled() + ? (sql: string) => { + traceSql('sql-main', 'query', { + sql: compactSqlForTrace(sql), + }); + } + : undefined, + }); + + if (isSqlTraceEnabled()) { + traceSql('sql-main', 'open', { + filePath, + readonly, + }); + } // Enable foreign keys sqlite.pragma('foreign_keys = ON'); @@ -508,6 +566,11 @@ export async function getReadOnlyDatabase(): Promise { export function closeDatabase(): void { if (sqlite) { sqlite.close(); + + if (isSqlTraceEnabled()) { + traceSql('sql-main', 'close'); + } + sqlite = null; db = null; initPromise = null; diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index f2fd5dbb7..d033e9e8f 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -87,6 +87,10 @@ export const content = sqliteTable( rating: text('rating'), added: text('added'), posterUrl: text('poster_url'), + epgChannelId: text('epg_channel_id'), + tvArchive: integer('tv_archive'), + tvArchiveDuration: integer('tv_archive_duration'), + directSource: text('direct_source'), xtreamId: integer('xtream_id').notNull(), type: text('type', { enum: ['live', 'movie', 'series'] }).notNull(), }, diff --git a/libs/shared/interfaces/src/lib/epg-program.model.ts b/libs/shared/interfaces/src/lib/epg-program.model.ts index 3a41c0b84..63ce10057 100644 --- a/libs/shared/interfaces/src/lib/epg-program.model.ts +++ b/libs/shared/interfaces/src/lib/epg-program.model.ts @@ -8,6 +8,8 @@ export interface EpgProgram { title: string; desc: string | null; category: string | null; + startTimestamp?: number | null; + stopTimestamp?: number | null; date?: string; episodeNum?: string | null; iconUrl?: string | null; diff --git a/libs/shared/interfaces/src/lib/xtream-code-actions.ts b/libs/shared/interfaces/src/lib/xtream-code-actions.ts index 06d08a15f..795d72234 100644 --- a/libs/shared/interfaces/src/lib/xtream-code-actions.ts +++ b/libs/shared/interfaces/src/lib/xtream-code-actions.ts @@ -9,4 +9,6 @@ export enum XtreamCodeActions { GetVodCategories = 'get_vod_categories', GetVodInfo = 'get_vod_info', GetShortEpg = 'get_short_epg', + GetSimpleDataTable = 'get_simple_data_table', + GetSimpleDateTable = 'get_simple_date_table', } diff --git a/libs/shared/interfaces/src/lib/xtream-item.interface.ts b/libs/shared/interfaces/src/lib/xtream-item.interface.ts index 969cd1e28..8d4d9d6b9 100644 --- a/libs/shared/interfaces/src/lib/xtream-item.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-item.interface.ts @@ -8,6 +8,9 @@ export interface XtreamItem { category_id: string; custom_sid: string; direct_source: string; + epg_channel_id?: string; + tv_archive?: number; + tv_archive_duration?: number; rating_imdb?: string; xtream_id?: number; type?: 'movie' | 'series' | 'live'; diff --git a/libs/shared/interfaces/src/lib/xtream-live-stream.interface.ts b/libs/shared/interfaces/src/lib/xtream-live-stream.interface.ts index eafbc345a..1c1339849 100644 --- a/libs/shared/interfaces/src/lib/xtream-live-stream.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-live-stream.interface.ts @@ -2,7 +2,7 @@ import { XtreamItem } from './xtream-item.interface'; export interface XtreamLiveStream extends XtreamItem { stream_type: 'live'; - epg_channel_id?: number; + epg_channel_id?: string; tv_archive: number; tv_archive_duration: number; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 7f5445e6e..d1e9e8f32 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -40,8 +40,11 @@ [shouldShowEpg]="shouldShowEpg()" [activeChannelUrl]="activeChannelUrl()" [favoriteIds]="favoriteIds()" + [sidebarWidth]="sidebarWidth()" (channelSelected)="onChannelSelected($event)" (favoriteToggled)="onFavoriteToggled($event)" + (sidebarWidthRequested)="onSidebarWidthRequested($event)" + (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" /> } @case ('favorites') { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 0f4e1120a..4da626dab 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -8,6 +8,7 @@ import { input, OnDestroy, OnInit, + output, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -113,6 +114,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Active view (all, groups, favorites, recent) */ readonly activeView = input('all'); readonly recentItems = input([]); + readonly sidebarWidth = input(null); + readonly sidebarWidthRequested = output(); + readonly sidebarWidthRequestEnded = output(); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); private readonly routeSearchTerm = queryParamSignal( this.route, @@ -375,4 +379,12 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { }) as any ); } + + onSidebarWidthRequested(width: number): void { + this.sidebarWidthRequested.emit(width); + } + + onSidebarWidthRequestEnded(width: number): void { + this.sidebarWidthRequestEnded.emit(width); + } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index bd2dc37cf..8b4a7a6c3 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -34,7 +34,7 @@
{{ - formatTime(program.start) + formatTime(program.start, program.startTimestamp) }}
{{ - formatTime(program.stop) + formatTime(program.stop, program.stopTimestamp) }}
} @else { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts new file mode 100644 index 000000000..887f87e81 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslateModule } from '@ngx-translate/core'; +import { EpgProgram } from 'shared-interfaces'; +import { ChannelListItemComponent } from './channel-list-item.component'; + +describe('ChannelListItemComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + ChannelListItemComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MatDialog, + useValue: { + open: jest.fn(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChannelListItemComponent); + }); + + it('formats preview times from timestamp fields when raw strings are offset', () => { + const startTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + const stopTimestamp = Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ); + const program: EpgProgram = { + start: '2026-04-05 03:00:00', + stop: '2026-04-05 03:30:00', + channel: 'channel-1', + title: 'Current Show', + desc: 'Current description', + category: null, + startTimestamp, + stopTimestamp, + }; + + fixture.componentRef.setInput('name', 'Cartoon Network'); + fixture.componentRef.setInput('epgProgram', program); + fixture.componentRef.setInput('showProgramInfoButton', false); + fixture.detectChanges(); + + const times = Array.from( + fixture.nativeElement.querySelectorAll('.epg-time'), + (element: Element) => element.textContent?.trim() ?? '' + ); + + expect(times).toEqual([ + new Date(startTimestamp * 1000).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), + new Date(stopTimestamp * 1000).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), + ]); + }); +}); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index fc795178f..8aee139d0 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -64,8 +64,20 @@ export class ChannelListItemComponent { /** * Formats time for display (HH:mm) */ - formatTime(dateString: string | number): string { - const date = new Date(dateString); + formatTime( + dateString: string | number, + timestampSeconds?: number | null + ): string { + const unixTimestamp = Number(timestampSeconds); + const date = + Number.isFinite(unixTimestamp) && unixTimestamp > 0 + ? new Date(unixTimestamp * 1000) + : new Date(dateString); + + if (Number.isNaN(date.getTime())) { + return ''; + } + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index abf936b29..407ab6faa 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -29,6 +29,9 @@ [maxWidth]="320" [defaultWidth]="208" storageKey="m3u-groups-nav-width" + (resizeStart)="onGroupsNavResizeStart()" + (widthChange)="onGroupsNavWidthChange($event)" + (resizeEnd)="onGroupsNavResizeEnd($event)" >
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index e0d3af919..1517027a1 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -121,6 +121,7 @@ describe('GroupsViewComponent', () => { groupedChannels: Record; progressTick: number; searchTerm: string; + sidebarWidth: number | null; shouldShowEpg: boolean; }> = {} ): void { @@ -149,6 +150,10 @@ describe('GroupsViewComponent', () => { 'favoriteIds', overrides.favoriteIds ?? new Set() ); + fixture.componentRef.setInput( + 'sidebarWidth', + overrides.sidebarWidth ?? 460 + ); fixture.detectChanges(); } @@ -250,6 +255,43 @@ describe('GroupsViewComponent', () => { }); }); + it('emits total sidebar width requests while resizing the groups rail', () => { + const requested = jest.fn(); + const committed = jest.fn(); + let contentWidth = 252; + + component.sidebarWidthRequested.subscribe(requested); + component.sidebarWidthRequestEnded.subscribe(committed); + + const contentPanel = fixture.nativeElement.querySelector( + '.groups-content-panel' + ) as HTMLElement; + + jest.spyOn(contentPanel, 'getBoundingClientRect').mockImplementation( + () => + ({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: contentWidth, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect + ); + + component.onGroupsNavResizeStart(); + contentWidth = 120; + + component.onGroupsNavWidthChange(260); + component.onGroupsNavResizeEnd(260); + + expect(requested).toHaveBeenCalledWith(512); + expect(committed).toHaveBeenCalledWith(512); + }); + it('renders the no-results state for searches without matches', () => { setInputs({ searchTerm: 'zzz' }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 2a06319b7..1a1c4d761 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, + OutputEmitterRef, computed, effect, inject, @@ -61,6 +62,9 @@ export class GroupsViewComponent { /** Set of favorite channel URLs */ readonly favoriteIds = input>(new Set()); + /** Current outer sidebar width */ + readonly sidebarWidth = input(null); + /** Emits when a channel is selected */ readonly channelSelected = output(); @@ -70,10 +74,17 @@ export class GroupsViewComponent { event: MouseEvent; }>(); + /** Emits while the groups rail requests a larger total sidebar width */ + readonly sidebarWidthRequested = output(); + + /** Emits when the groups rail resize ends */ + readonly sidebarWidthRequestEnded = output(); + readonly selectedGroupKey = signal(null); readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); private previousActiveChannelUrl: string | undefined; + private preservedContentWidth = 0; constructor() { effect(() => { @@ -254,6 +265,19 @@ export class GroupsViewComponent { this.selectedGroupKey.set(groupKey); } + onGroupsNavResizeStart(): void { + this.preservedContentWidth = this.measureContentPanelWidth(); + } + + onGroupsNavWidthChange(width: number): void { + this.emitSidebarWidthRequest(width, this.sidebarWidthRequested); + } + + onGroupsNavResizeEnd(width: number): void { + this.emitSidebarWidthRequest(width, this.sidebarWidthRequestEnded); + this.preservedContentWidth = 0; + } + trackByChannel(_: number, channel: Channel): string { return channel?.id; } @@ -318,4 +342,74 @@ export class GroupsViewComponent { const elapsed = Math.min(total, Math.max(0, now - start)); return Math.round((elapsed / total) * 100); } + + private emitSidebarWidthRequest( + navWidth: number, + emitter: OutputEmitterRef + ): void { + const preservedContentWidth = + this.preservedContentWidth || this.measureContentPanelWidth(navWidth); + const requestedWidth = Math.round(navWidth + preservedContentWidth); + + if (requestedWidth > 0) { + emitter.emit(requestedWidth); + } + } + + private measureContentPanelWidth(currentNavWidth?: number): number { + const contentPanel = this.hostEl.nativeElement.querySelector( + '.groups-content-panel' + ); + const measuredContentWidth = this.readWidth(contentPanel); + if (measuredContentWidth > 0) { + return measuredContentWidth; + } + + const hostWidth = this.readWidth(this.hostEl.nativeElement); + const totalWidth = + hostWidth > 0 ? hostWidth : Math.max(0, this.sidebarWidth() ?? 0); + const navPanel = this.hostEl.nativeElement.querySelector( + '.groups-nav-panel' + ); + const navWidth = currentNavWidth ?? this.readWidth(navPanel); + + if (totalWidth > 0 && navWidth > 0) { + return Math.max(0, totalWidth - navWidth); + } + + return 0; + } + + private readWidth(element: Element | null): number { + if (!element) { + return 0; + } + + const rectWidth = element.getBoundingClientRect().width; + if (rectWidth > 0) { + return rectWidth; + } + + if (!(element instanceof HTMLElement)) { + return 0; + } + + if (element.offsetWidth > 0) { + return element.offsetWidth; + } + + const inlineWidth = Number.parseFloat(element.style.width); + if (Number.isFinite(inlineWidth) && inlineWidth > 0) { + return inlineWidth; + } + + const computedWidth = Number.parseFloat( + window.getComputedStyle(element).width + ); + if (Number.isFinite(computedWidth) && computedWidth > 0) { + return computedWidth; + } + + return 0; + } } diff --git a/libs/ui/epg/project.json b/libs/ui/epg/project.json index bc80a674b..5dcfa878d 100644 --- a/libs/ui/epg/project.json +++ b/libs/ui/epg/project.json @@ -6,6 +6,21 @@ "projectType": "library", "tags": ["scope:shared", "domain:playback", "type:ui"], "targets": { + "test": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "command": [ + "node", + "./tools/testing/run-web-esm-lib-tests.mjs", + "libs/ui/epg/src" + ], + "env": { + "NODE_OPTIONS": "--experimental-vm-modules" + }, + "forwardAllArgs": true + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts index cc3e4d442..3c97f1575 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts @@ -21,6 +21,8 @@ describe('EpgItemDescriptionComponent', () => { { provide: MAT_DIALOG_DATA, useValue: { + start: '2026-04-05T11:30:00.000Z', + stop: '2026-04-05T12:30:00.000Z', title: 'TV Show 1', desc: 'Highly interesting show about pets', category: 'Fun', @@ -44,21 +46,21 @@ describe('EpgItemDescriptionComponent', () => { it('should render epg details in the dialog', () => { fixture.detectChanges(); const titleElement = fixture.debugElement.query( - By.css('[data-test="title"]') + By.css('.program-title') ); expect(titleElement.nativeElement.textContent.trim()).toContain( 'TV Show 1' ); const categoryElement = fixture.debugElement.query( - By.css('[data-test="category"]') + By.css('.category-tag') ); expect(categoryElement.nativeElement.textContent.trim()).toContain( 'Fun' ); const descElement = fixture.debugElement.query( - By.css('[data-test="desc"]') + By.css('.program-description') ); expect(descElement.nativeElement.textContent.trim()).toContain( 'Highly interesting show about pets' diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts index 92f23bfef..17355cba5 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts @@ -91,6 +91,7 @@ describe('EpgListItemComponent', () => { expect(dialog.open).toHaveBeenCalledTimes(1); expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, { data: {}, + width: '800px', }); }); }); diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html index 8349c4f9e..73ffb9caa 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html @@ -74,14 +74,7 @@ timeNow() >= program.start && timeNow() <= program.stop " - (click)=" - setEpgProgram( - program, - timeNow() >= program.start && - timeNow() <= program.stop, - program.start > timeshiftLimit - ) - " + (click)="activateProgram(program)" >
{ - const momentFunc = () => ({ - format: () => '2023-01-01', - subtract: () => ({ - toISOString: () => '2023-01-01T00:00:00Z', - format: () => '2023-01-01', - }), - diff: () => 0, - add: () => ({ - format: () => '2023-01-01', - }), - }); - momentFunc.default = momentFunc; - return momentFunc; -}); +let requestAnimationFrameQueue: FrameRequestCallback[] = []; + +@Component({ + selector: 'app-epg-list-item', + standalone: true, + template: '', +}) +class StubEpgListItemComponent { + readonly item = input(); + readonly timeNow = input(''); + readonly timeshiftUntil = input(''); +} describe('EpgListComponent', () => { - let component: EpgListComponent; let fixture: ComponentFixture; - let electronService: DataService; - let mockStore: MockStore; - let epgService: EpgService; - const actions$ = new Observable(); + let component: EpgListComponent; + let store: { select: jest.Mock; dispatch: jest.Mock }; + let epgService: { currentEpgPrograms$: BehaviorSubject }; + let originalRequestAnimationFrame: typeof window.requestAnimationFrame; - const MOCKED_PROGRAMS = { - channel: { - id: '12345', - name: [ - { - lang: 'ab', - value: 'test me', - }, - { - lang: 'ar', - value: 'ar test', - }, - ], - icon: [], - url: [], + const fixedNow = new Date('2026-04-05T12:00:00.000Z'); + const controlledChannel: Channel = { + id: 'channel-101', + name: 'Channel 101', + url: 'https://example.com/live.m3u8', + group: { title: 'News' }, + tvg: { + id: 'channel-101', + name: 'Channel 101', + url: '', + logo: 'channel-101.png', + rec: '3', }, - items: [ - { - start: '2023-01-01', - stop: '2023-01-01', - channel: '12345', - title: [{ lang: 'en', value: 'NOW on PBS' }], - desc: [ - { - lang: 'en', - value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.", - }, - ], - date: ['20080711'], - category: [ - { lang: 'en', value: 'Newsmagazine' }, - { lang: 'en', value: 'Interview' }, - ], - episodeNum: [ - { system: 'dd_progid', value: 'EP01006886.0028' }, - { system: 'onscreen', value: '427' }, - ], - previouslyShown: [{ start: '20080711000000' }], - subtitles: [{ type: 'teletext' }], - rating: [ - { - system: 'VCHIP', - value: 'TV-G', - }, - ], - credits: [ - { - role: 'actor', - name: 'Peter Bergman', - }, - ], - icon: [ - 'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg', - ], - audio: [], - _attributes: { - start: '2023-01-01', - stop: '2023-01-01', - }, - }, - ], + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: 'false', + epgParams: '', }; - beforeEach(waitForAsync(() => { - const mockEpgService = { + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + originalRequestAnimationFrame = window.requestAnimationFrame; + requestAnimationFrameQueue = []; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + requestAnimationFrameQueue.push(callback); + return requestAnimationFrameQueue.length; + }) as typeof window.requestAnimationFrame; + + store = { + select: jest.fn().mockReturnValue(of(null)), + dispatch: jest.fn(), + }; + epgService = { currentEpgPrograms$: new BehaviorSubject([]), }; - TestBed.configureTestingModule({ - imports: [ - EpgListComponent, - MockPipe(MomentDatePipe), - TranslateModule.forRoot(), - MockComponent(EpgListItemComponent), - MockModule(MatIconModule), - MockModule(MatTooltipModule), - MockModule(MatListModule), - MockModule(MatDialogModule), - ], + await TestBed.configureTestingModule({ + imports: [EpgListComponent, NoopAnimationsModule], providers: [ - { provide: DataService, useClass: ElectronServiceStub }, - { provide: EpgService, useValue: mockEpgService }, - MockProvider(MatDialog), - provideMockStore(), - provideMockActions(actions$), + { provide: Store, useValue: store }, + { provide: EpgService, useValue: epgService }, ], - }).compileComponents(); - })); + }) + .overrideComponent(EpgListComponent, { + remove: { + imports: [ + EpgListItemComponent, + MomentDatePipe, + TranslatePipe, + ], + }, + add: { + imports: [ + StubEpgListItemComponent, + MockPipe( + MomentDatePipe, + ( + value: string | null | undefined, + format?: string + ) => { + if (!value) { + return ''; + } + + const parsed = new Date(value); + if (format === 'HH:mm') { + return parsed + .toISOString() + .slice(11, 16); + } + + return value.slice(0, 10); + } + ), + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(EpgListComponent); component = fixture.componentInstance; - electronService = TestBed.inject(DataService); - epgService = TestBed.inject(EpgService); + fixture.componentRef.setInput('controlledChannel', controlledChannel); + fixture.componentRef.setInput('controlledArchiveDays', 3); + }); + + afterEach(() => { + fixture.destroy(); + window.requestAnimationFrame = originalRequestAnimationFrame; + jest.useRealTimers(); + }); + + it('auto-scrolls once on the initial current-day load', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); - mockStore = TestBed.inject(MockStore); - mockStore.setState({ - playlistState: { - active: { - id: '', - url: '', - name: '', - group: { title: '' }, - tvg: { - rec: '3', - }, - } as unknown as Channel, - }, - }); fixture.detectChanges(); + const scrollToSpy = configureScrollableProgramList(fixture); + + flushAnimationFrames(); + + expect(scrollToSpy).toHaveBeenCalledTimes(1); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 172 }); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('does not re-trigger auto-scroll on timer-driven updates', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + + fixture.detectChanges(); + const scrollToSpy = configureScrollableProgramList(fixture); + flushAnimationFrames(); + + scrollToSpy.mockClear(); + jest.advanceTimersByTime(30_000); + fixture.detectChanges(); + flushAnimationFrames(); + + expect(scrollToSpy).not.toHaveBeenCalled(); }); - it('should handle an empty epg programs object', () => { - const emptyPrograms: EpgProgram[] = []; - (epgService.currentEpgPrograms$ as BehaviorSubject).next( - emptyPrograms + it('keeps older same-day rows reachable after the initial scroll', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + + fixture.detectChanges(); + const scrollToSpy = configureScrollableProgramList(fixture); + const programList = fixture.nativeElement.querySelector( + '#program-list' + ) as HTMLElement; + flushAnimationFrames(); + + scrollToSpy.mockClear(); + programList.scrollTop = 0; + jest.advanceTimersByTime(30_000); + fixture.detectChanges(); + flushAnimationFrames(); + + expect(programList.scrollTop).toBe(0); + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + + it('does not re-arm auto-scroll when changing dates for the same selection', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + + fixture.detectChanges(); + const scrollToSpy = configureScrollableProgramList(fixture); + flushAnimationFrames(); + + scrollToSpy.mockClear(); + component.changeDate('prev'); + fixture.detectChanges(); + component.changeDate('next'); + fixture.detectChanges(); + flushAnimationFrames(); + + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + + it('re-arms auto-scroll when the effective selection changes', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + + fixture.detectChanges(); + let scrollToSpy = configureScrollableProgramList(fixture); + flushAnimationFrames(); + scrollToSpy.mockClear(); + + fixture.componentRef.setInput( + 'controlledPrograms', + buildPrograms({ + currentStart: '2026-04-05T11:45:00.000Z', + currentStop: '2026-04-05T12:45:00.000Z', + futureStart: '2026-04-05T12:45:00.000Z', + futureStop: '2026-04-05T13:45:00.000Z', + }) ); - component.handleEpgData(emptyPrograms); fixture.detectChanges(); - expect(component.timeNow).toBeTruthy(); - expect(component.dateToday).toBeTruthy(); - expect(component.channel).toBeNull(); - // Use async pipe or subscribe to test the Observable - component.items$.subscribe((items) => { - expect(items).toHaveLength(0); - }); - }); + scrollToSpy = configureScrollableProgramList(fixture); + flushAnimationFrames(); - it('should set epg program as active', () => { - jest.spyOn(mockStore, 'dispatch'); - component.setEpgProgram(MOCKED_PROGRAMS.items[0], false, true); - expect(mockStore.dispatch).toHaveBeenCalledTimes(1); - expect(mockStore.dispatch).toHaveBeenCalledWith({ - program: MOCKED_PROGRAMS.items[0], - type: expect.stringContaining('epg program'), - }); - }); - - it('should reset active epg program', () => { - jest.spyOn(mockStore, 'dispatch'); - component.setEpgProgram(MOCKED_PROGRAMS.items[0], true); - expect(mockStore.dispatch).toHaveBeenCalledTimes(1); - component.setEpgProgram(MOCKED_PROGRAMS.items[0], true, true); - expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + expect(scrollToSpy).toHaveBeenCalledTimes(1); }); }); - */ + +function buildPrograms(overrides?: { + currentStart?: string; + currentStop?: string; + futureStart?: string; + futureStop?: string; +}): EpgProgram[] { + const pastStart = '2026-04-05T09:00:00.000Z'; + const pastStop = '2026-04-05T10:00:00.000Z'; + const currentStart = + overrides?.currentStart ?? '2026-04-05T11:30:00.000Z'; + const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z'; + const futureStart = + overrides?.futureStart ?? '2026-04-05T12:30:00.000Z'; + const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z'; + + return [ + buildProgram('past', 'Past Show', pastStart, pastStop), + buildProgram('current', 'Current Show', currentStart, currentStop), + buildProgram('future', 'Future Show', futureStart, futureStop), + ]; +} + +function buildProgram( + channelSuffix: string, + title: string, + start: string, + stop: string +): EpgProgram { + return { + start, + stop, + channel: `channel-${channelSuffix}`, + title, + desc: null, + category: null, + startTimestamp: Math.floor(Date.parse(start) / 1000), + stopTimestamp: Math.floor(Date.parse(stop) / 1000), + }; +} + +function configureScrollableProgramList( + fixture: ComponentFixture +): jest.Mock { + const programList = fixture.nativeElement.querySelector( + '#program-list' + ) as HTMLElement; + const currentProgram = fixture.nativeElement.querySelector( + '.program-item.current-program' + ) as HTMLElement; + + let scrollTop = 0; + Object.defineProperty(programList, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }); + Object.defineProperty(programList, 'clientHeight', { + configurable: true, + value: 120, + }); + Object.defineProperty(currentProgram, 'offsetTop', { + configurable: true, + value: 240, + }); + Object.defineProperty(currentProgram, 'offsetHeight', { + configurable: true, + value: 36, + }); + + const scrollToSpy = jest.fn(({ top }: { top: number }) => { + scrollTop = top; + }); + programList.scrollTo = scrollToSpy as typeof programList.scrollTo; + + return scrollToSpy; +} + +function flushAnimationFrames(): void { + while (requestAnimationFrameQueue.length > 0) { + const callback = requestAnimationFrameQueue.shift(); + callback?.(0); + } +} diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts index b5560c656..c036baa86 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts @@ -1,16 +1,16 @@ import { ChangeDetectionStrategy, Component, + ElementRef, computed, effect, - ElementRef, inject, input, + output, signal, viewChild, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; @@ -25,10 +25,14 @@ import { EpgListItemComponent } from './epg-list-item/epg-list-item.component'; const DATE_FORMAT = 'YYYY-MM-DD'; +export interface EpgProgramActivationEvent { + program: EpgProgram; + type: 'live' | 'timeshift'; +} + @Component({ imports: [ EpgListItemComponent, - FormsModule, MatIcon, MatIconButton, MatTooltip, @@ -43,14 +47,15 @@ const DATE_FORMAT = 'YYYY-MM-DD'; export class EpgListComponent { readonly controlledChannel = input(null); readonly controlledPrograms = input(null); + readonly controlledArchiveDays = input(null); + readonly programActivated = output(); private readonly store = inject(Store); private readonly epgService = inject(EpgService); - private readonly activeChannel = toSignal( - this.store.select(selectActive), - { initialValue: null } - ); + private readonly activeChannel = toSignal(this.store.select(selectActive), { + initialValue: null, + }); private readonly servicePrograms = toSignal( this.epgService.currentEpgPrograms$, { initialValue: [] as EpgProgram[] } @@ -85,44 +90,47 @@ export class EpgListComponent { readonly items = computed( () => this.controlledPrograms() ?? this.servicePrograms() ?? [] ); - readonly timeshiftUntil = computed(() => { + readonly archiveDays = computed(() => { + const controlledArchiveDays = this.controlledArchiveDays(); + if (controlledArchiveDays !== null) { + return Math.max(0, controlledArchiveDays); + } + const channel = this.displayChannel(); const value = channel?.tvg?.rec || channel?.timeshift || channel?.catchup?.days; - const days = Number(value ?? 0) || 0; - return moment().subtract(days, 'days').toISOString(); + return Math.max(0, Number(value ?? 0) || 0); }); + readonly timeshiftUntil = computed(() => + moment() + .subtract(this.archiveDays(), 'days') + .toISOString() + ); readonly filteredItems = computed(() => [...this.items()] .filter( (item) => - moment(item.start).format(DATE_FORMAT) === this.selectedDate() + moment(item.start).format(DATE_FORMAT) === + this.selectedDate() + ) + .sort( + (left, right) => + getProgramTimeMs(left.start, left.startTimestamp) - + getProgramTimeMs(right.start, right.startTimestamp) ) - .sort((a, b) => moment(a.start).diff(moment(b.start))) ); private scrollScheduled = false; + private activeScrollContextKey: string | null = null; + private lastAutoScrolledContextKey: string | null = null; constructor() { effect((onCleanup) => { - const programList = this.programList()?.nativeElement; - if (!programList || typeof ResizeObserver === 'undefined') { - return; - } + const intervalId = window.setInterval(() => { + this.timeNow.set(new Date().toISOString()); + }, 30_000); - const resizeObserver = new ResizeObserver((entries) => { - const [entry] = entries; - if ( - entry && - entry.contentRect.height > 0 && - entry.contentRect.width > 0 - ) { - this.scheduleScrollToCurrentProgram(); - } - }); - - resizeObserver.observe(programList); - onCleanup(() => resizeObserver.disconnect()); + onCleanup(() => clearInterval(intervalId)); }); effect(() => { @@ -148,9 +156,7 @@ export class EpgListComponent { }) ); } else { - this.store.dispatch( - EpgActions.resetActiveEpgProgram() - ); + this.store.dispatch(EpgActions.resetActiveEpgProgram()); } } else { this.store.dispatch(EpgActions.resetActiveEpgProgram()); @@ -161,6 +167,7 @@ export class EpgListComponent { this.store.dispatch(EpgActions.resetActiveEpgProgram()); } + this.updateScrollContext(channel, programs); this.scheduleScrollToCurrentProgram(); }); } @@ -171,58 +178,106 @@ export class EpgListComponent { [direction === 'next' ? 'add' : 'subtract'](1, 'days') .format(DATE_FORMAT) ); - this.scheduleScrollToCurrentProgram(); } - setEpgProgram( - program: EpgProgram, - isLive?: boolean, - timeshift?: boolean - ): void { - if (!this.isControlled()) { - if (isLive) { - this.store.dispatch(EpgActions.resetActiveEpgProgram()); - } else if (timeshift) { - this.store.dispatch( - EpgActions.setActiveEpgProgram({ program }) - ); - } else { - return; - } + activateProgram(program: EpgProgram): void { + const isLive = this.isProgramPlaying(program); + const isTimeshift = this.isProgramArchived(program); + + if (!isLive && !isTimeshift) { + return; + } + + if (this.isControlled()) { + this.programActivated.emit({ + program, + type: isLive ? 'live' : 'timeshift', + }); + this.timeNow.set(new Date().toISOString()); + return; + } + + if (isLive) { + this.store.dispatch(EpgActions.resetActiveEpgProgram()); + } else { + this.store.dispatch(EpgActions.setActiveEpgProgram({ program })); } this.timeNow.set(new Date().toISOString()); } + canActivateProgram(program: EpgProgram): boolean { + return this.isProgramPlaying(program) || this.isProgramArchived(program); + } + calculateProgress(program: EpgProgram): number { - const now = new Date().getTime(); - const start = new Date(program.start).getTime(); - const stop = new Date(program.stop).getTime(); + const now = Date.now(); + const start = getProgramTimeMs(program.start, program.startTimestamp); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); const total = stop - start; const elapsed = now - start; - return Math.min(100, Math.max(0, (elapsed / total) * 100)); + return total > 0 ? Math.min(100, Math.max(0, (elapsed / total) * 100)) : 0; } isProgramPlaying(program: EpgProgram): boolean { - const currentTime = this.timeNow(); - return currentTime >= program.start && currentTime <= program.stop; + const now = Date.now(); + const start = getProgramTimeMs(program.start, program.startTimestamp); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); + return now >= start && now <= stop; + } + + isProgramArchived(program: EpgProgram): boolean { + if (this.archiveDays() <= 0) { + return false; + } + + const now = Date.now(); + const start = getProgramTimeMs(program.start, program.startTimestamp); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); + const archiveLimit = Date.parse(this.timeshiftUntil()); + + return stop < now && start >= archiveLimit; } private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined { - const now = new Date().toISOString(); - return programs.find((item) => { - const start = new Date(item.start).toISOString(); - const stop = new Date(item.stop).toISOString(); + const now = Date.now(); + return programs.find((program) => { + const start = getProgramTimeMs( + program.start, + program.startTimestamp + ); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); return now >= start && now <= stop; }); } + private updateScrollContext( + channel: Channel | null, + programs: EpgProgram[] + ): void { + const nextContextKey = buildScrollContextKey(channel, programs); + if (nextContextKey === this.activeScrollContextKey) { + return; + } + + this.activeScrollContextKey = nextContextKey; + this.scrollScheduled = false; + } + private scheduleScrollToCurrentProgram(): void { if (this.selectedDate() !== moment().format(DATE_FORMAT)) { return; } + const scrollContextKey = this.activeScrollContextKey; + if ( + !scrollContextKey || + this.lastAutoScrolledContextKey === scrollContextKey + ) { + return; + } + if (this.scrollScheduled) { return; } @@ -231,12 +286,12 @@ export class EpgListComponent { requestAnimationFrame(() => { requestAnimationFrame(() => { this.scrollScheduled = false; - this.scrollCurrentProgramIntoView(); + this.scrollCurrentProgramIntoView(scrollContextKey); }); }); } - private scrollCurrentProgramIntoView(): void { + private scrollCurrentProgramIntoView(scrollContextKey: string): void { const container = this.programList()?.nativeElement; const currentProgram = container?.querySelector( '.program-item.current-program' @@ -246,6 +301,8 @@ export class EpgListComponent { return; } + this.lastAutoScrolledContextKey = scrollContextKey; + const viewTop = container.scrollTop; const viewBottom = viewTop + container.clientHeight; const elementTop = currentProgram.offsetTop; @@ -258,11 +315,39 @@ export class EpgListComponent { if (elementBottom > viewBottom) { container.scrollTo({ - top: - elementBottom - - container.clientHeight + - 16, + top: elementBottom - container.clientHeight + 16, }); } } } + +function buildScrollContextKey( + channel: Channel | null, + programs: EpgProgram[] +): string | null { + if (!channel && programs.length === 0) { + return null; + } + + const channelKey = + channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel'; + const programKey = programs + .map( + (program) => + `${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}` + ) + .join('|'); + + return `${channelKey}:${programKey}`; +} + +function getProgramTimeMs( + isoValue: string, + timestampValue?: number | null +): number { + if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) { + return Number(timestampValue) * 1000; + } + + return Date.parse(isoValue); +} diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index fc3b288de..68413c06d 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -21,4 +21,7 @@ [channelList]="channels()" [activeView]="activeView()" [recentItems]="activePlaylist()?.recentlyViewed ?? []" + [sidebarWidth]="sidebarWidth()" + (sidebarWidthRequested)="onSidebarWidthRequested($event)" + (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" /> diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 6b5134eea..10bac6372 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, input } from '@angular/core'; +import { Component, computed, inject, input, output } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; @@ -27,6 +27,9 @@ export class SidebarComponent { readonly channels = input([]); readonly showPlaylistHeader = input(true); readonly activeView = input('all'); + readonly sidebarWidth = input(null); + readonly sidebarWidthRequested = output(); + readonly sidebarWidthRequestEnded = output(); private readonly playlistContext = inject(PlaylistContextFacade); private readonly translate = inject(TranslateService); @@ -48,4 +51,12 @@ export class SidebarComponent { const count = this.channels()?.length ?? 0; return `${count} ${this.translate.instant('HOME.PLAYLISTS.CHANNELS')}`; }); + + onSidebarWidthRequested(width: number): void { + this.sidebarWidthRequested.emit(width); + } + + onSidebarWidthRequestEnded(width: number): void { + this.sidebarWidthRequestEnded.emit(width); + } } diff --git a/libs/vercel.json b/libs/vercel.json new file mode 100644 index 000000000..a6948060b --- /dev/null +++ b/libs/vercel.json @@ -0,0 +1,5 @@ +{ + "git": { + "deploymentEnabled": false + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss index b0b80eb3c..ea949a985 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss @@ -27,7 +27,7 @@ .settings-panel-footer { flex-shrink: 0; - padding: 12px 8px 12px; + padding: 12px 8px calc(20px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--app-separator); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index b32664127..26e217a41 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -373,6 +373,17 @@ describe('WorkspaceShellFacade', () => { ); }); + it('applies q to Xtream category search on vod routes', () => { + const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + + facade.currentUrl.set('/workspace/xtreams/pl-1/vod?q=neo'); + (facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute(); + TestBed.flushEffects(); + + expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith('neo'); + expect(xtreamStore.setSearchTerm).not.toHaveBeenCalled(); + }); + it('enables local-filter search on playlist favorites routes', () => { facade.currentUrl.set('/workspace/playlists/pl-1/favorites?q=news'); (facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();