Merge pull request #854 from 4gray/feat/xtream-epg

Feat: Xtream Code API EPG improvements
This commit is contained in:
4gray authored and GitHub committed 2026-04-06 18:18:17 +02:00
commit 595213d3dc
139 files changed
+9265 -1060

No files matched your search

@@ -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<void> {
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<string, number>();
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<string, number>();
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(
@@ -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<void> {
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<void> {
async function playFirstSeriesEpisode(page: Page): Promise<void> {
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<void> {
await episodeCard.click();
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
@@ -175,5 +201,7 @@ async function toggleFavoriteForChannel(page: Page, title: string): Promise<void
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/);
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -681,6 +681,20 @@ export async function clickCategoryById(
await expect(category.first()).toBeVisible({ timeout: 20000 });
await category.first().scrollIntoViewIfNeeded();
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);
}
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 {
@@ -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<void> {
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<void> {
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<void> {
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 20000 })
.toMatch(pattern);
}
async function countDbEvents(
page: Page,
operation: string,
status: string
): Promise<number> {
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<void> {
await page.getByTestId(selectTestId).click();
await page.getByTestId(optionTestId).click();
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
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/);
}
@@ -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<T> = {
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<XtreamEpgFixture> {
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<T>(
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<string, string>
@@ -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<void> {
+67 -25
View File
@@ -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();
@@ -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<typeof channelItemByTitle>[0]) {
return page
.locator('app-epg-list .program-title')
.allInnerTexts()
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
}
async function getProgressWidthPercent(row: ReturnType<typeof channelItemByTitle>) {
const style = await row.locator('.epg-progress-fill').getAttribute('style');
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
return Number.parseFloat(width);
}
@@ -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, {
+3 -5
View File
@@ -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": {
@@ -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<T extends Record<string, unknown>>(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<unknown>).then ===
'function'
) {
return (result as Promise<unknown>)
.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));
+95 -1
View File
@@ -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')
@@ -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<string, unknown> & {
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,
})
@@ -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();
@@ -245,3 +245,46 @@ type ActiveXtreamRequest = {
};
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
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,
};
}
}
);
@@ -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<TResult>((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')
@@ -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<string, unknown>,
depth: number
): Record<string, unknown> {
const entries = Object.entries(value);
const summary: Record<string, unknown> = {};
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<string, unknown>, 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)
)}`
);
}
@@ -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<AppDatabase> {
}
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;
}
+23
View File
@@ -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();
});
+2 -11
View File
@@ -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 }) => {
+2 -16
View File
@@ -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<void> {
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);
+145 -17
View File
@@ -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<void> {
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<void> {
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<XtreamLiveStream> {
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', '');
}
@@ -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<Playlist>) {
const title = payload.title?.trim() || undefined;
window.electron
.fetchPlaylistByUrl(payload.url)
.fetchPlaylistByUrl(payload.url, title)
.then((result) => {
this.store.dispatch(
PlaylistActions.handleAddingPlaylistByUrl({
+13 -1
View File
@@ -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<Playlist>): 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,
})
);
});
@@ -817,7 +817,7 @@
</div>
</section>
<div class="action-bar">
<footer class="action-bar" data-test-id="settings-action-bar">
@if (isDialog) {
<button
mat-button
@@ -840,7 +840,7 @@
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
</button>
</div>
</footer>
</div>
</form>
</div>
@@ -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 {
@@ -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',
}
);
});
+128 -36
View File
@@ -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<typeof window.setTimeout> | 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<void> => {
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,
});
}
}
+2
View File
@@ -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.",
+4
View File
@@ -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));
}
+4
View File
@@ -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<Playlist>;
+11
View File
@@ -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=<id>` | Full movie details |
| `get_series_info?series_id=<id>` | Full series info (seasons + episodes) |
| `get_short_epg?stream_id=<id>[&limit=N]` | EPG listings for a live channel |
| `get_simple_data_table?stream_id=<id>` | Full per-channel EPG schedule |
| `get_simple_date_table?stream_id=<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+
+209 -10
View File
@@ -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<number, RawEpgListing[]>;
}
const portalCache = new Map<string, PortalData>();
const vodDetailsCache = new Map<number, RawVodDetails>();
const seriesInfoCache = new Map<number, RawSeriesInfo>();
const epgCache = new Map<number, RawEpgListing[]>();
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<number, RawEpgListing[]>();
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<number, RawEpgListing[]>();
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;
}
@@ -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', '');
}
@@ -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<string, string>;
if (!stream_id) {
res.json({ epg_listings: [] });
return;
}
const id = parseInt(stream_id, 10);
res.json({ epg_listings: getEpgListings(username, password, id) });
}
@@ -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<string, string>;
const {
username = '',
password = '',
stream_id,
limit,
} = req.query as Record<string, string>;
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 });
}
@@ -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}` });
}
@@ -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<string, ScenarioConfig> = {
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',
+11
View File
@@ -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=<serverUrl>&action=<action>&username=X&password=Y
+25 -10
View File
@@ -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
+1 -1
View File
@@ -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**
@@ -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.
+97
View File
@@ -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.
@@ -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.
+2 -2
View File
@@ -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
+119
View File
@@ -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`
@@ -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
+4 -4
View File
@@ -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:
+509
View File
@@ -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 `<base href="./">` 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.
+5 -5
View File
@@ -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` | `<app-epg-view>` 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` | `<app-epg-view>` 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<EpgItem[]>
+26 -18
View File
@@ -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:
@@ -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.
+102 -154
View File
@@ -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<string, unknown>;
}
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.
+128
View File
@@ -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.
+42
View File
@@ -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 |
| `<any other>` | 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`
@@ -42,7 +42,7 @@
/>
}
@case ('url') {
<app-url-upload (urlAdded)="sendPlaylistsUrl($event)" />
<app-url-upload />
}
@case ('text') {
<app-text-import (textAdded)="uploadAsText($event)" />
@@ -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 }}
</button>
@@ -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();
});
});
@@ -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;
}
}
@@ -7,6 +7,14 @@
formControlName="playlistUrl"
/>
</mat-form-field>
<mat-form-field class="w-full">
<input
type="text"
matInput
[placeholder]="'HOME.XTREAM_PLAYLIST.TITLE' | translate"
formControlName="playlistName"
/>
</mat-form-field>
@if (!isDesktop) {
<p>
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
@@ -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();
});
});
@@ -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<string> = new EventEmitter();
form: FormGroup;
form!: FormGroup<{
playlistName: FormControl<string>;
playlistUrl: FormControl<string>;
}>;
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: [''],
});
}
}
@@ -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';
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class M3uCollectionRouteComponent {
private readonly route = inject(ActivatedRoute);
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('m3u');
readonly playlistId = routeParamSignal<string | undefined>(
this.route,
'id',
(value) => value ?? undefined
);
}
@@ -1,16 +1,22 @@
<div
class="sidebar"
appResizable
[minWidth]="200"
[maxWidth]="600"
[defaultWidth]="460"
storageKey="m3u-sidebar-width"
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
[defaultWidth]="sidebarWidth()"
[style.width.px]="sidebarWidth()"
[storageKey]="sidebarStorageKey()"
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onGroupedSidebarWidthRequestEnded($event)"
/>
} @loading {
<p>Loading...</p>
@@ -77,11 +83,9 @@
</div>
}
} @else {
<div class="empty-state-container">
<mat-icon class="empty-state-icon">live_tv</mat-icon>
<h2 class="empty-state-title">
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
</h2>
</div>
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -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;
@@ -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();
@@ -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<Channel[]> = 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
@@ -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<PlaylistMeta> & { _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<PlaylistSwitcherComponent>;
let component: PlaylistSwitcherComponent;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
let resolvedPlaylistIdSignal: ReturnType<typeof signal<string | null>>;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let playlistContext: {
playlists: typeof playlistsSignal;
allPlaylistsLoaded: ReturnType<typeof signal<boolean>>;
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<void> {
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<string | null>(xtreamPlaylist._id);
activePlaylistSignal = signal<PlaylistMeta | null>(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');
});
});
@@ -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<PlaylistMeta> & { _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<NavigationEnd>;
let router: {
url: string;
events: ReturnType<Subject<NavigationEnd>['asObservable']>;
navigate: jest.Mock;
};
let dispatch: jest.Mock;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
let loadedSignal: ReturnType<typeof signal<boolean>>;
let activePlaylistIdSignal: ReturnType<typeof signal<string | null>>;
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<NavigationEnd>();
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<string | null>(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<PlaylistRouteContext>({
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',
},
});
});
});
@@ -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) {
@@ -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<CategoryContentViewComponent>;
const paramMap$ = new ReplaySubject(1);
const queryParamMap$ = new ReplaySubject(1);
const isPaginatedContentLoading = signal(true);
const categoryItemCount = signal(0);
const contentSortMode = signal<PortalCatalogSortMode | null>(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');
});
});
@@ -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 {
@@ -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<XtreamCategory[]>([]);
readonly favorites = input<FavoriteLayoutItem[]>([]);
@@ -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<UnifiedCollectionPageComponent>;
let route: ActivatedRoute & {
snapshot: {
paramMap: ReturnType<typeof convertToParamMap>;
queryParamMap: ReturnType<typeof convertToParamMap>;
queryParams: Record<string, unknown>;
params: Record<string, unknown>;
data: Record<string, unknown>;
parent: null;
};
paramMap: ReturnType<
BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']
>;
queryParamMap: ReturnType<
BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']
>;
pathFromRoot: ActivatedRoute[];
};
let routeParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let routeQueryParamMap$: BehaviorSubject<
ReturnType<typeof convertToParamMap>
>;
let workspaceParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
const playlistsLoaded = signal(false);
const playlists = signal<PlaylistMeta[]>([]);
const favoritesData = {
@@ -72,10 +94,83 @@ describe('UnifiedCollectionPageComponent', () => {
clearRecentItems: jest.fn(),
};
function toParamMapRecord(
values: Record<string, unknown>
): Record<string, string> {
return Object.fromEntries(
Object.entries(values)
.filter(([, value]) => value != null)
.map(([key, value]) => [key, String(value)])
);
}
function setRouteParams(params: Record<string, unknown>): void {
const nextParamMap = convertToParamMap(toParamMapRecord(params));
route.snapshot.params = params;
route.snapshot.paramMap = nextParamMap;
routeParamMap$.next(nextParamMap);
}
function setRouteQueryParams(queryParams: Record<string, unknown>): 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<typeof convertToParamMap>;
queryParamMap: ReturnType<typeof convertToParamMap>;
queryParams: Record<string, unknown>;
params: Record<string, unknown>;
data: Record<string, unknown>;
parent: null;
};
paramMap: ReturnType<
BehaviorSubject<
ReturnType<typeof convertToParamMap>
>['asObservable']
>;
queryParamMap: ReturnType<
BehaviorSubject<
ReturnType<typeof convertToParamMap>
>['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'
);
});
});
@@ -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<string>();
readonly playlistIdInput = input<string | undefined>(undefined, {
alias: 'playlistId',
});
readonly defaultScope = input<CollectionScope>();
private readonly route = inject(ActivatedRoute);
@@ -65,11 +70,25 @@ export class UnifiedCollectionPageComponent implements OnInit {
selectPlaylistsLoadingFlag
);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly queryScope = queryParamSignal<CollectionScope | null>(
this.route,
'scope',
(value) =>
value === 'all' || value === 'playlist' ? value : null
);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim()
);
private readonly routePlaylistId = routeParamSignal<string | undefined>(
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<CollectionScope>('playlist');
private readonly persistedScope = computed(() =>
this.scopeService.getScope(this.scopeKey())()
);
readonly scope = linkedSignal<CollectionScope>(() => {
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<CollectionScope>(() =>
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<void> {
private async loadData(params: {
mode: 'favorites' | 'recent';
portalType?: string;
playlistId?: string;
scope: CollectionScope;
}): Promise<void> {
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;
}
@@ -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<void> {
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<void> {
private async loadEpgMap(items: UnifiedCollectionItem[]): Promise<void> {
const epgMap = await this.streamResolver.loadEpgForItems(items);
this.epgMap.set(epgMap);
}
@@ -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<Playlist>)
);
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),
},
],
});
@@ -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<string, XtreamEpgCacheEntry>();
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
private readonly xtreamEpgCacheTtlMs = 60 * 1000;
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
private async getElectronPlaylist(
playlistId: string
): Promise<Playlist | undefined> {
if (typeof window === 'undefined') {
return undefined;
}
try {
return (
(await window.electron?.dbGetAppPlaylist?.(playlistId)) ??
undefined
);
} catch {
return undefined;
}
}
async resolvePlayback(
item: UnifiedCollectionItem
): Promise<ResolvedPortalPlayback> {
@@ -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<ResolvedLiveCollectionDetail> {
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<ResolvedLiveCollectionDetail> {
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<string, unknown> | undefined)?.[
(item.stalkerItem as Record<string, unknown> | 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<EpgItem[]> {
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 ?? ''),
@@ -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(
@@ -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();
});
});
@@ -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<T> = (value: string | null) => T;
type RouteParamNormalizer<T> = (value: string | null) => T;
const PORTAL_SECTIONS = new Set<PortalRailSection>([
'all',
@@ -54,6 +55,37 @@ export function queryParamSignal<T = string>(
);
}
export function routeParamSignal<T = string>(
route: ActivatedRoute,
key: string,
normalizer?: RouteParamNormalizer<T>
): Signal<T> {
const normalize =
normalizer ??
(((
value: string | null
) => (value ?? '') as T) as RouteParamNormalizer<T>);
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(
@@ -46,6 +46,7 @@ export interface PortalCatalogFacade<
readonly playlist: Signal<PortalCatalogPlaylistMeta | null>;
initialize(categoryId?: string | null): void;
setSearchQuery?(query: string): void;
clearSelectedItem(): void;
setPage(page: number): void;
setLimit(limit: number): void;
@@ -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
@@ -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)
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-stalker-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/stalker/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/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',
],
};
+8
View File
@@ -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"
}
@@ -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';
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerCollectionRouteComponent {
private readonly route = inject(ActivatedRoute);
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('stalker');
readonly playlistId = routeParamSignal<string | undefined>(
this.route,
'id',
(value) => value ?? undefined
);
}
@@ -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<void> {
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<NavigationEnd>();
const activePlaylist = signal<PlaylistMeta | null>(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]
);
});
});
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -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"
]
}
@@ -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
@@ -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<Promise<unknown>, [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);
});
});
@@ -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<string, RawEpgListing>;
}
/**
@@ -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<EpgItem[]> {
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
*/
@@ -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<Promise<string | null>, [string]>;
setAppState: jest.Mock<Promise<void>, [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'
);
});
});
@@ -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<string, XtreamCatchupScheme>();
private readonly catchupSchemeRequests = new Map<
string,
Promise<XtreamCatchupScheme>
>();
/**
* 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<string> {
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<XtreamCatchupScheme> {
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<XtreamCatchupScheme> {
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<number> {
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())}`;
}
}
@@ -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<any[]>(),
@@ -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<boolean> => {
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<boolean> {
const ctx = getCredentialsFromStore();
if (!ctx) {
return false;
}
return hasCompletedOfflineCache(ctx.playlistId);
},
async retryContentInitialization(): Promise<void> {
const portalStatus =
(await getPortalStore().checkPortalStatus?.()) ??
@@ -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<typeof TestEpgStore>;
let xtreamApiService: {
getFullEpg: jest.Mock<Promise<EpgItem[]>, unknown[]>;
getShortEpg: jest.Mock<Promise<EpgItem[]>, 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);
});
});
@@ -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<EpgState>(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 ?? ''));
}
@@ -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);
});
@@ -1,4 +1,6 @@
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@let streamUrl = activeStreamUrl();
@let epgPrograms = controlledEpgPrograms();
@if (selectedCategoryId()) {
<div
@@ -79,8 +81,41 @@
}
<div class="epg">
<div class="epg-content">
@if (epgItems().length > 0) {
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else if (isElectron && epgPrograms.length > 0) {
@if (showArchiveUnavailableNotice()) {
<div class="archive-unavailable-banner" role="status">
<div class="archive-unavailable-banner__title">
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE'
| translate
}}
</div>
<div
class="archive-unavailable-banner__description"
>
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION'
| translate
}}
</div>
</div>
}
<app-epg-list
[controlledPrograms]="epgPrograms"
[controlledArchiveDays]="controlledArchiveDays()"
(programActivated)="onProgramActivated($event)"
/>
} @else if (!isElectron && epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
} @else {
<app-portal-empty-state
icon="event_busy"
[message]="'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate"
/>
}
</div>
</div>
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more