mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #854 from 4gray/feat/xtream-epg
Feat: Xtream Code API EPG improvements
This commit is contained in:
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> {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Vendored
+4
@@ -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>;
|
||||
|
||||
@@ -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+
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+105
@@ -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();
|
||||
});
|
||||
});
|
||||
+18
-5
@@ -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: [''],
|
||||
});
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
|
||||
+66
-2
@@ -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
|
||||
|
||||
+268
@@ -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) {
|
||||
|
||||
+27
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
+21
-4
@@ -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
-5
@@ -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[]>([]);
|
||||
|
||||
+191
-18
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+75
-47
@@ -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;
|
||||
}
|
||||
|
||||
+16
-8
@@ -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;
|
||||
|
||||
+19
-1
@@ -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
|
||||
|
||||
+20
-10
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+36
-1
@@ -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
Reference in new issue
Block a user