mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/analyze-second-bug-907512
This commit is contained in:
commit
276a7b756b
89 files changed
+5091
-683
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
---
|
||||
|
||||
The EPG timeline toolbar is more compact: "Now" is an icon button, and the zoom slider is replaced by a single button that cycles day overview → by hour → detailed. Ctrl/⌘ + scroll (or a trackpad pinch) over the timeline zooms smoothly around the cursor. The channel and programme title now use all the space the controls free up.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
issues: [1520]
|
||||
---
|
||||
|
||||
Xtream and Stalker keep remote channel order while you browse other categories or search. Use Show playing channel to return to the current channel without restarting playback. Stalker radio supports the same behavior.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
issues: [1543]
|
||||
---
|
||||
|
||||
Stalker Live TV search stays within the selected category in the sidebar and fullscreen panel, including channels beyond the first page. All Items searches the whole catalog, and the two search fields work independently.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: web-backend
|
||||
issues: [1436]
|
||||
---
|
||||
|
||||
The self-hosted backend now checks every provider redirect and pins connections to validated addresses, preventing redirects or DNS changes from bypassing private-network restrictions. Trusted LAN access remains available through the existing opt-in.
|
||||
@@ -207,6 +207,19 @@ invalidation prevent late results from restoring removed programmes. Provider
|
||||
EPG is independent. See `docs/architecture/m3u-playlist-module.md`
|
||||
("XMLTV source lifecycle").
|
||||
|
||||
## Web Backend Provider Redirects
|
||||
|
||||
All four provider proxy routes use `ValidatedHttpClient`: automatic redirects
|
||||
are disabled, the initial URL and at most five redirect hops pass full URL/DNS
|
||||
validation, and fresh agents pin each connection to that hop's validated IPs.
|
||||
Host/SNI and TLS verification remain intact; outbound environment proxies are
|
||||
disabled. Private-network opt-in applies to the chain. Cross-origin redirects
|
||||
strip session headers; original query params are not replayed. One portal
|
||||
admission owns the entire chain and final body, with explicit redirect evidence
|
||||
preventing destination failures from penalizing the initial endpoint. Contracts:
|
||||
`docs/architecture/pwa-self-hosted.md` and
|
||||
`docs/architecture/host-connectivity-guard.md`.
|
||||
|
||||
## Portal Connectivity Preference
|
||||
|
||||
- Half-open trial slots follow the complete request lifetime with no elapsed-time
|
||||
@@ -227,6 +240,26 @@ EPG is independent. See `docs/architecture/m3u-playlist-module.md`
|
||||
copy and Retry now; Stalker preserves cached account data on a failed refresh.
|
||||
Contract: `docs/architecture/host-connectivity-guard.md`.
|
||||
|
||||
## Live Channel Return
|
||||
|
||||
Xtream and Stalker (including radio) capture displayed playback order on explicit
|
||||
selection. Remote up/down, numbers and status use that queue while browsing
|
||||
categories or search. Stalker commits after successful current URL resolution
|
||||
and extends only loaded pages of the original scope. The conditional channel
|
||||
header action clears search, returns to the accessible playing category and
|
||||
focuses its row without changing playback. Contract:
|
||||
`docs/architecture/remote-control.md` (Live channel return and playback order).
|
||||
|
||||
## Stalker Live Search
|
||||
|
||||
ITV sidebar and fullscreen searches independently filter the complete selected
|
||||
category; only All Items searches the whole public catalog. Cached categories
|
||||
search before windowing; missing/censored genres keep provider pagination,
|
||||
including automatic continuation for short or empty search results. ITV search
|
||||
never narrows shared provider pages or resets their index. Category changes
|
||||
reset list windows and retain playback/active EPG. Contract:
|
||||
`docs/architecture/stalker-portal.md` (Full ITV Channel List Cache).
|
||||
|
||||
## Channel and Detail Keyboard Scrolling
|
||||
|
||||
Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
|
||||
|
||||
@@ -1694,6 +1694,19 @@ invalidation prevent late results from restoring removed programmes. Provider
|
||||
EPG is independent. See `docs/architecture/m3u-playlist-module.md`
|
||||
("XMLTV source lifecycle").
|
||||
|
||||
## Web Backend Provider Redirects
|
||||
|
||||
All four provider proxy routes use `ValidatedHttpClient`: automatic redirects
|
||||
are disabled, the initial URL and at most five redirect hops pass full URL/DNS
|
||||
validation, and fresh agents pin each connection to that hop's validated IPs.
|
||||
Host/SNI and TLS verification remain intact; outbound environment proxies are
|
||||
disabled. Private-network opt-in applies to the chain. Cross-origin redirects
|
||||
strip session headers; original query params are not replayed. One portal
|
||||
admission owns the entire chain and final body, with explicit redirect evidence
|
||||
preventing destination failures from penalizing the initial endpoint. Contracts:
|
||||
`docs/architecture/pwa-self-hosted.md` and
|
||||
`docs/architecture/host-connectivity-guard.md`.
|
||||
|
||||
## Portal Connectivity Preference
|
||||
|
||||
- Half-open trial slots follow the complete request lifetime with no elapsed-time
|
||||
@@ -1714,6 +1727,26 @@ EPG is independent. See `docs/architecture/m3u-playlist-module.md`
|
||||
copy and Retry now; Stalker preserves cached account data on a failed refresh.
|
||||
Contract: `docs/architecture/host-connectivity-guard.md`.
|
||||
|
||||
## Live Channel Return
|
||||
|
||||
Xtream and Stalker (including radio) capture displayed playback order on explicit
|
||||
selection. Remote up/down, numbers and status use that queue while browsing
|
||||
categories or search. Stalker commits after successful current URL resolution
|
||||
and extends only loaded pages of the original scope. The conditional channel
|
||||
header action clears search, returns to the accessible playing category and
|
||||
focuses its row without changing playback. Contract:
|
||||
`docs/architecture/remote-control.md` (Live channel return and playback order).
|
||||
|
||||
## Stalker Live Search
|
||||
|
||||
ITV sidebar and fullscreen searches independently filter the complete selected
|
||||
category; only All Items searches the whole public catalog. Cached categories
|
||||
search before windowing; missing/censored genres keep provider pagination,
|
||||
including automatic continuation for short or empty search results. ITV search
|
||||
never narrows shared provider pages or resets their index. Category changes
|
||||
reset list windows and retain playback/active EPG. Contract:
|
||||
`docs/architecture/stalker-portal.md` (Full ITV Channel List Cache).
|
||||
|
||||
## Channel and Detail Keyboard Scrolling
|
||||
|
||||
Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
|
||||
|
||||
@@ -78,25 +78,60 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
.first();
|
||||
await expect(nowBlock).toBeVisible();
|
||||
|
||||
// Zooming re-renders the ribbon: block widths grow with px/minute.
|
||||
const zoomInput = timeline.locator(
|
||||
'.epg-timeline__zoom input[type="range"]'
|
||||
// The zoom button cycles hours → detail → day; block widths follow
|
||||
// px/minute, so "detail" must render the same block wider than "day".
|
||||
const zoomButton = timeline.locator('.epg-timeline__zoom');
|
||||
await expect(zoomButton).toBeVisible();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'hours');
|
||||
|
||||
const blockWidth = async () =>
|
||||
(await nowBlock.boundingBox())?.width ?? 0;
|
||||
|
||||
await zoomButton.click();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
|
||||
const detailZoomWidth = await blockWidth();
|
||||
|
||||
await zoomButton.click();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'day');
|
||||
const dayZoomWidth = await blockWidth();
|
||||
expect(detailZoomWidth).toBeGreaterThan(dayZoomWidth);
|
||||
|
||||
// Ctrl + wheel over the ribbon fine-tunes the zoom (and must not
|
||||
// trigger Chromium's page zoom, which would scale the whole window).
|
||||
const ribbon = timeline.locator('.epg-timeline__ribbon');
|
||||
await ribbon.hover({ position: { x: 200, y: 40 } });
|
||||
const pageZoomBefore = await app.mainWindow.evaluate(
|
||||
() => window.devicePixelRatio
|
||||
);
|
||||
await expect(zoomInput).toBeVisible();
|
||||
await app.mainWindow.keyboard.down('Control');
|
||||
await app.mainWindow.mouse.wheel(0, -300);
|
||||
await app.mainWindow.keyboard.up('Control');
|
||||
await expect
|
||||
.poll(blockWidth, { timeout: 5000 })
|
||||
.toBeGreaterThan(dayZoomWidth);
|
||||
// Chromium page zoom scales devicePixelRatio; the ribbon must have
|
||||
// swallowed the gesture instead.
|
||||
expect(
|
||||
await app.mainWindow.evaluate(() => window.devicePixelRatio)
|
||||
).toBe(pageZoomBefore);
|
||||
|
||||
const blockWidthAt = async (zoom: 'min' | 'max') => {
|
||||
await zoomInput.evaluate((element, target) => {
|
||||
const input = element as HTMLInputElement;
|
||||
input.value = target === 'min' ? input.min : input.max;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, zoom);
|
||||
const box = await nowBlock.boundingBox();
|
||||
return box?.width ?? 0;
|
||||
// The contrast checks below read the block's time line, which the
|
||||
// narrower tiers hide, so settle on the "detail" preset. The
|
||||
// wheel-tuned scale may sit in any band (Chromium scales the delivered
|
||||
// delta), so cycle from wherever it landed rather than assuming a
|
||||
// fixed number of clicks.
|
||||
const clicksToDetail: Record<string, number> = {
|
||||
detail: 0,
|
||||
day: 2,
|
||||
hours: 1,
|
||||
};
|
||||
|
||||
const minZoomWidth = await blockWidthAt('min');
|
||||
const maxZoomWidth = await blockWidthAt('max');
|
||||
expect(maxZoomWidth).toBeGreaterThan(minZoomWidth);
|
||||
const landedLevel =
|
||||
(await zoomButton.getAttribute('data-zoom-level')) ?? '';
|
||||
expect(Object.keys(clicksToDetail)).toContain(landedLevel);
|
||||
for (let i = 0; i < clicksToDetail[landedLevel]; i += 1) {
|
||||
await zoomButton.click();
|
||||
}
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
|
||||
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
|
||||
@@ -2,23 +2,30 @@ import { APIRequestContext, Page } from '@playwright/test';
|
||||
import { AddressInfo, createServer as createNetServer } from 'net';
|
||||
|
||||
import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
enableRemoteControl,
|
||||
expect,
|
||||
fillWorkspaceSearch,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForM3uCatalog,
|
||||
waitForStalkerCatalog,
|
||||
waitForXtreamCatalog,
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
type RemoteControlStatus = {
|
||||
channelName?: string;
|
||||
channelNumber?: number;
|
||||
isLiveView: boolean;
|
||||
muted?: boolean;
|
||||
portal: 'm3u' | 'xtream' | 'stalker' | 'unknown';
|
||||
@@ -27,6 +34,235 @@ type RemoteControlStatus = {
|
||||
};
|
||||
|
||||
test.describe('Electron Remote Control', () => {
|
||||
for (const mode of ['xtream', 'stalker', 'radio'] as const) {
|
||||
test(`@remote-control @electron ${mode} keeps playback order while browsing and reveals the playing channel`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const provider = mode === 'xtream' ? 'xtream' : 'stalker';
|
||||
await resetMockServers(request, [provider]);
|
||||
const remotePort = await reserveFreePort();
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'playback');
|
||||
await selectSettingsOption(
|
||||
page,
|
||||
'select-video-player',
|
||||
'artplayer'
|
||||
);
|
||||
await enableRemoteControl(page, remotePort);
|
||||
await saveSettings(page);
|
||||
await waitForRemoteControlServer(request, remotePort);
|
||||
await goToDashboard(page);
|
||||
if (provider === 'xtream') {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamCatalog(page);
|
||||
} else {
|
||||
await addStalkerPortal(page);
|
||||
await waitForStalkerCatalog(page);
|
||||
}
|
||||
await page
|
||||
.getByRole('link', {
|
||||
name: mode === 'radio' ? 'Radio' : 'Live TV',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const categories = page.locator(
|
||||
'.context-panel .category-item:not([data-category-id="*"])'
|
||||
);
|
||||
await expect(categories.nth(1)).toBeVisible();
|
||||
const originalCategory = categories.first();
|
||||
const browsedCategory = categories.nth(1);
|
||||
await originalCategory.click();
|
||||
const rows = page
|
||||
.locator('#live-channels')
|
||||
.getByTestId('channel-item');
|
||||
await expect(rows.nth(2)).toBeVisible();
|
||||
if (provider === 'xtream') {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Sort channels',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Name Z-A' })
|
||||
.click();
|
||||
}
|
||||
const titles = await rows
|
||||
.locator('.channel-name')
|
||||
.allTextContents();
|
||||
const [first, second, third] = titles.map((title) =>
|
||||
title.trim()
|
||||
);
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).toBeTruthy();
|
||||
expect(third).toBeTruthy();
|
||||
await rows.first().click();
|
||||
await waitForRemoteStatus(
|
||||
request,
|
||||
remotePort,
|
||||
(status) =>
|
||||
status.portal === provider &&
|
||||
status.channelName === first &&
|
||||
status.channelNumber === 1
|
||||
);
|
||||
await browsedCategory.click();
|
||||
await expect(browsedCategory).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
);
|
||||
if (provider === 'xtream') {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Sort channels',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Name A-Z' })
|
||||
.click();
|
||||
}
|
||||
await fillWorkspaceSearch(page, '__no_playing_channel__');
|
||||
await page.waitForURL(/q=__no_playing_channel__/);
|
||||
await expect(rows).toHaveCount(0);
|
||||
await waitForRemoteStatus(
|
||||
request,
|
||||
remotePort,
|
||||
(status) =>
|
||||
status.portal === provider &&
|
||||
status.channelName === first &&
|
||||
status.channelNumber === 1
|
||||
);
|
||||
await postRemoteCommand(
|
||||
request,
|
||||
remotePort,
|
||||
'/channel/select-number',
|
||||
{ number: 2 }
|
||||
);
|
||||
await waitForRemoteStatus(
|
||||
request,
|
||||
remotePort,
|
||||
(status) =>
|
||||
status.channelName === second &&
|
||||
status.channelNumber === 2
|
||||
);
|
||||
await expect(browsedCategory).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
);
|
||||
await postRemoteCommand(request, remotePort, '/channel/down');
|
||||
await waitForRemoteStatus(
|
||||
request,
|
||||
remotePort,
|
||||
(status) =>
|
||||
status.channelName === third &&
|
||||
status.channelNumber === 3
|
||||
);
|
||||
await postRemoteCommand(request, remotePort, '/channel/up');
|
||||
await waitForRemoteStatus(
|
||||
request,
|
||||
remotePort,
|
||||
(status) =>
|
||||
status.channelName === second &&
|
||||
status.channelNumber === 2
|
||||
);
|
||||
await expect(browsedCategory).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
);
|
||||
const media = page
|
||||
.locator(
|
||||
mode === 'radio'
|
||||
? 'app-audio-player audio'
|
||||
: 'app-web-player-view video'
|
||||
)
|
||||
.first();
|
||||
await expect(media).toBeAttached();
|
||||
const originalMedia = await media.elementHandle();
|
||||
const originalSource = await media.evaluate(
|
||||
(element: HTMLMediaElement) => element.src
|
||||
);
|
||||
const reveal = page.getByRole('button', {
|
||||
name: 'Show playing channel',
|
||||
exact: true,
|
||||
});
|
||||
await expect(reveal).toBeVisible();
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
if (theme === 'dark') {
|
||||
await expect(page.locator('body')).toHaveClass(
|
||||
/dark-theme/
|
||||
);
|
||||
} else {
|
||||
await expect(page.locator('body')).not.toHaveClass(
|
||||
/dark-theme/
|
||||
);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: test
|
||||
.info()
|
||||
.outputPath(`${mode}-show-playing-${theme}.png`),
|
||||
});
|
||||
}
|
||||
await reveal.click();
|
||||
await expect(originalCategory).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
);
|
||||
await expect(
|
||||
page.locator(
|
||||
'app-workspace-shell-header input[type="search"]'
|
||||
)
|
||||
).toHaveValue('');
|
||||
await expect(page.locator('#live-channels')).toBeFocused();
|
||||
const activeRow = channelItemByTitle(page, second).first();
|
||||
await expect(activeRow).toBeVisible();
|
||||
await expect(activeRow).toHaveClass(/active/);
|
||||
await expect(reveal).toHaveCount(0);
|
||||
expect(
|
||||
await media.evaluate(
|
||||
(element, previous) => element === previous,
|
||||
originalMedia
|
||||
)
|
||||
).toBe(true);
|
||||
await expect(media).toHaveJSProperty('src', originalSource);
|
||||
await waitForRemoteStatus(
|
||||
request,
|
||||
remotePort,
|
||||
(status) =>
|
||||
status.channelName === second &&
|
||||
status.channelNumber === 2
|
||||
);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${mode}-revealed-dark.png`),
|
||||
});
|
||||
// Category-only root browsing does not change the URL; a
|
||||
// router no-op must still reveal without restarting playback.
|
||||
await browsedCategory.click();
|
||||
await expect(reveal).toBeVisible();
|
||||
await reveal.click();
|
||||
await expect(originalCategory).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
);
|
||||
await expect(activeRow).toBeVisible();
|
||||
expect(
|
||||
await media.evaluate(
|
||||
(element, previous) => element === previous,
|
||||
originalMedia
|
||||
)
|
||||
).toBe(true);
|
||||
await originalMedia?.dispose();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@remote-control @m3u @electron applies remote volume commands to the selected built-in video player', async ({
|
||||
dataDir,
|
||||
request,
|
||||
@@ -247,10 +483,11 @@ async function getRemoteStatus(
|
||||
async function postRemoteCommand(
|
||||
request: APIRequestContext,
|
||||
port: number,
|
||||
path: string
|
||||
path: string,
|
||||
data: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
const response = await request.post(remoteControlUrl(port, path), {
|
||||
data: {},
|
||||
data,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
@@ -261,9 +498,12 @@ function remoteControlUrl(port: number, path: string): string {
|
||||
}
|
||||
|
||||
async function readMediaVolume(page: Page, selector: string): Promise<number> {
|
||||
return page.locator(selector).first().evaluate((element) => {
|
||||
return Number((element as HTMLMediaElement).volume.toFixed(2));
|
||||
});
|
||||
return page
|
||||
.locator(selector)
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
return Number((element as HTMLMediaElement).volume.toFixed(2));
|
||||
});
|
||||
}
|
||||
|
||||
function roundVolume(volume: number | undefined): number | null {
|
||||
|
||||
@@ -51,9 +51,7 @@ test('@electron @stalker built-in player plays an auth-gated portal stream', asy
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
// The portal lands on Movies; live playback lives in the ITV layout.
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: /live|itv/i })
|
||||
.click();
|
||||
await app.mainWindow.getByRole('link', { name: /live|itv/i }).click();
|
||||
await app.mainWindow.waitForURL(/stalker.*itv/);
|
||||
|
||||
// The ITV view renders channels only after a category is selected;
|
||||
@@ -84,6 +82,40 @@ test('@electron @stalker built-in player plays an auth-gated portal stream', asy
|
||||
{ timeout: 20_000 }
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
// Search/category changes must keep the playing native-hosted engine
|
||||
// while both ITV categories contain the same search word (#1543).
|
||||
const playingVideo = await video.elementHandle();
|
||||
await categories.nth(1).click();
|
||||
const search = app.mainWindow.locator('input[type="search"]').first();
|
||||
await search.fill('TV');
|
||||
await search.press('Enter');
|
||||
await expect(app.mainWindow).toHaveURL(/q=TV/);
|
||||
await expect(
|
||||
app.mainWindow.locator('#live-channels .channel-name')
|
||||
).toHaveCount(5);
|
||||
const firstNames = await app.mainWindow
|
||||
.locator('#live-channels .channel-name')
|
||||
.allTextContents();
|
||||
await categories.nth(2).click();
|
||||
await expect(
|
||||
app.mainWindow.locator('#live-channels .channel-name')
|
||||
).toHaveCount(5);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const names = await app.mainWindow
|
||||
.locator('#live-channels .channel-name')
|
||||
.allTextContents();
|
||||
return names.every((name) => !firstNames.includes(name));
|
||||
})
|
||||
.toBe(true);
|
||||
expect(
|
||||
await playingVideo?.evaluate((element) => element.isConnected)
|
||||
).toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate((element: HTMLVideoElement) => element.paused)
|
||||
)
|
||||
.toBe(false);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('playback-diagnostic-banner')
|
||||
).toBeHidden();
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
* see `@iptvnator/shared/host-health`) fast-fails the rest once a host has
|
||||
* refused to answer twice in a row.
|
||||
*
|
||||
* On the axios timeout semantics: with the default (follow-redirects)
|
||||
* transport, `timeout` is NOT a wall-clock deadline for the whole response. It
|
||||
* bounds the time to response headers, and then continues as the socket's
|
||||
* On the axios timeout semantics: with axios 1.20's native HTTP
|
||||
* transport (`maxRedirects: 0`), each hop's `timeout` is NOT a wall-clock
|
||||
* deadline for the whole response. It bounds the time to response headers, and then continues as the socket's
|
||||
* inactivity timeout for the body. A large XMLTV or M3U download that keeps
|
||||
* delivering bytes is therefore never cut off mid-transfer — only a stalled one
|
||||
* is, which is exactly the case these numbers are meant to catch.
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
portalEndpointKeyOf,
|
||||
} from '@iptvnator/shared/host-health';
|
||||
import { buildHostConnectivityFastFailMessage } from '@iptvnator/shared/interfaces';
|
||||
import { ProviderRequestError } from './provider-request-error';
|
||||
import type { NormalizedProviderError } from './provider-error';
|
||||
|
||||
/**
|
||||
@@ -176,8 +177,8 @@ export function resetProviderHost(
|
||||
* `countFailures: false` is for requests exempt from the guard (endpoint
|
||||
* discovery). Their failures are expected and must not count — but the report
|
||||
* must still happen, because an error that carries an HTTP response proves the
|
||||
* endpoint answered. This route sets no `validateStatus`, so axios rejects every
|
||||
* non-2xx WITH `error.response`; dropping those instead of reporting them is
|
||||
* endpoint answered. The redirect wrapper accepts 3xx, but axios still rejects
|
||||
* other non-2xx WITH `error.response`; dropping those instead of reporting them is
|
||||
* what would let the breaker open in the middle of discovery.
|
||||
*/
|
||||
export function reportProviderRequestFailure(
|
||||
@@ -190,6 +191,14 @@ export function reportProviderRequestFailure(
|
||||
return;
|
||||
}
|
||||
|
||||
const manualChain = error instanceof ProviderRequestError;
|
||||
if (error instanceof ProviderRequestError) {
|
||||
if (error.initialResponded) {
|
||||
guard.reportSuccess(token);
|
||||
return;
|
||||
}
|
||||
error = error.cause;
|
||||
}
|
||||
const countFailures = options.countFailures ?? true;
|
||||
switch (classifyHostRequestFailure(error)) {
|
||||
case 'host-level':
|
||||
@@ -199,7 +208,10 @@ export function reportProviderRequestFailure(
|
||||
// other response — otherwise an ordinary timeout, a probe that was
|
||||
// redirected to a dead destination, and another ordinary timeout
|
||||
// still read as two consecutive failures.
|
||||
if (failedAfterRedirect(error, token, options.requestUrl)) {
|
||||
if (
|
||||
!manualChain &&
|
||||
failedAfterRedirect(error, token, options.requestUrl)
|
||||
) {
|
||||
guard.reportSuccess(token);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
request as httpRequest,
|
||||
ClientRequest,
|
||||
IncomingMessage,
|
||||
RequestOptions,
|
||||
} from 'node:http';
|
||||
import {
|
||||
request as httpsRequest,
|
||||
Agent as HttpsAgent,
|
||||
AgentOptions,
|
||||
} from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import { checkServerIdentity } from 'node:tls';
|
||||
import type {
|
||||
ProviderTransportOptions,
|
||||
WebBackendHttpClient,
|
||||
WebBackendHttpResponse,
|
||||
} from './validated-http-client';
|
||||
|
||||
/**
|
||||
* Keep HTTP authority metadata separate from socket destination selection.
|
||||
* Only the hop's pinned agent can select an address; axios receives a fixed
|
||||
* logical hostname, never a user-selected connection authority. Host and TLS
|
||||
* identity still describe the validated provider, including IP certificates.
|
||||
*/
|
||||
export class ProviderAxiosTransport implements WebBackendHttpClient {
|
||||
async get<T>(
|
||||
url: string,
|
||||
options: ProviderTransportOptions = {}
|
||||
): Promise<WebBackendHttpResponse<T>> {
|
||||
const target = new URL(axios.getUri({ url, params: options.params }));
|
||||
const secure = target.protocol === 'https:';
|
||||
const hostname = target.hostname.replace(/^\[|\]$/g, '');
|
||||
const port = Number(target.port || (secure ? 443 : 80));
|
||||
const root = secure
|
||||
? 'https://provider.invalid/'
|
||||
: 'http://provider.invalid/';
|
||||
if (!options.httpAgent || !options.httpsAgent) {
|
||||
throw new Error('Provider transport requires pinned agents');
|
||||
}
|
||||
const agent = options.httpsAgent as HttpsAgent & {
|
||||
options: AgentOptions;
|
||||
};
|
||||
agent.options.servername = isIP(hostname) ? '' : hostname;
|
||||
agent.options.checkServerIdentity = (_name, certificate) =>
|
||||
checkServerIdentity(hostname, certificate);
|
||||
const response = await axios.get<T>(root, {
|
||||
...options,
|
||||
params: undefined,
|
||||
transport: {
|
||||
request: (
|
||||
requestOptions: RequestOptions,
|
||||
callback: (response: IncomingMessage) => void
|
||||
) => {
|
||||
// Path is HTTP metadata, never parsed as an authority.
|
||||
// Even //host/path remains a path on the pinned connection.
|
||||
const request = (secure ? httpsRequest : httpRequest)(
|
||||
{
|
||||
...requestOptions,
|
||||
hostname: 'provider.invalid',
|
||||
port,
|
||||
path: target.pathname + target.search,
|
||||
},
|
||||
callback
|
||||
);
|
||||
retainHeaderTimeout(request, options.timeout);
|
||||
return request;
|
||||
},
|
||||
},
|
||||
headers: { ...options.headers, Host: target.host },
|
||||
});
|
||||
return {
|
||||
...response,
|
||||
headers: {
|
||||
location:
|
||||
typeof response.headers['location'] === 'string'
|
||||
? response.headers['location']
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Axios identifies native transports by identity; a custom one needs this timer. */
|
||||
function retainHeaderTimeout(request: ClientRequest, timeout?: number): void {
|
||||
if (!timeout) return;
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
request.destroy(
|
||||
Object.assign(
|
||||
new Error('Provider response headers timed out'),
|
||||
{ code: 'ECONNABORTED' }
|
||||
)
|
||||
),
|
||||
timeout
|
||||
);
|
||||
timer.unref();
|
||||
const clear = () => {
|
||||
clearTimeout(timer);
|
||||
request.removeListener('response', clear);
|
||||
request.removeListener('error', clear);
|
||||
request.removeListener('close', clear);
|
||||
};
|
||||
request.once('response', clear);
|
||||
request.once('error', clear);
|
||||
request.once('close', clear);
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
* routinely holds Xtream credentials.
|
||||
*/
|
||||
|
||||
import { ProviderRequestError } from './provider-request-error';
|
||||
import { providerUrlErrorBody } from './provider-url-policy';
|
||||
|
||||
export interface ProviderError extends Error {
|
||||
readonly code?: unknown;
|
||||
readonly cause?: unknown;
|
||||
@@ -78,6 +81,10 @@ function visitErrorNode(
|
||||
export function normalizeProviderError(
|
||||
error: unknown
|
||||
): NormalizedProviderError {
|
||||
if (error instanceof ProviderRequestError) {
|
||||
if (error.policyError) return providerUrlErrorBody(error.policyError);
|
||||
return normalizeProviderError(error.cause);
|
||||
}
|
||||
const providerError = error as ProviderError | null | undefined;
|
||||
const response = providerError?.response;
|
||||
if (
|
||||
@@ -111,6 +118,10 @@ export function logProviderRequestFailure(options: {
|
||||
}
|
||||
|
||||
function describeProviderFailure(error: unknown): string {
|
||||
if (error instanceof ProviderRequestError)
|
||||
return error.policyError
|
||||
? `URL policy ${error.policyError.status}`
|
||||
: describeProviderFailure(error.cause);
|
||||
const providerError = error as ProviderError | null | undefined;
|
||||
const response = providerError?.response;
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ProviderUrlError } from './provider-url-policy';
|
||||
|
||||
/** Internal chain evidence, separate from the deliberately small public body. */
|
||||
export class ProviderRequestError extends Error {
|
||||
constructor(
|
||||
readonly initialResponded: boolean,
|
||||
readonly cause: unknown,
|
||||
readonly policyError?: ProviderUrlError
|
||||
) {
|
||||
super('Provider request failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Socket } from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
/** Redirect bodies are irrelevant, including invalid gzip and endless bodies. */
|
||||
export function discardProviderBody(data: unknown): void {
|
||||
if (data instanceof Readable) data.destroy();
|
||||
}
|
||||
|
||||
export function discardProviderErrorBody(error: unknown): void {
|
||||
const response = (error as { response?: { data?: unknown } } | null)
|
||||
?.response;
|
||||
discardProviderBody(response?.data);
|
||||
}
|
||||
|
||||
/** Match axios's buffered default and arraybuffer modes for the final hop. */
|
||||
export async function readProviderBody<T>(
|
||||
data: T,
|
||||
arraybuffer: boolean,
|
||||
timeout?: number,
|
||||
socket?: Socket
|
||||
): Promise<T> {
|
||||
if (!(data instanceof Readable)) return data;
|
||||
const chunks: Buffer[] = [];
|
||||
// Axios resolves stream responses at headers and then ignores its own
|
||||
// request timeout callback. Keep the native socket's inactivity timer
|
||||
// alive until the body finishes; arriving wire bytes reset it, even if
|
||||
// decompression has not emitted another decoded chunk yet.
|
||||
const onTimeout = () =>
|
||||
data.destroy(
|
||||
Object.assign(new Error('Provider response body timed out'), {
|
||||
code: 'ECONNABORTED',
|
||||
})
|
||||
);
|
||||
if (timeout && socket) socket.setTimeout(timeout, onTimeout);
|
||||
try {
|
||||
for await (const chunk of data) chunks.push(Buffer.from(chunk));
|
||||
} finally {
|
||||
if (timeout && socket) {
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
socket.setTimeout(0);
|
||||
}
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
if (arraybuffer) return buffer as T;
|
||||
const text = buffer.toString('utf8').replace(/^\uFEFF/, '');
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return text as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { validateProviderUrl } from './provider-url-policy';
|
||||
|
||||
const publicV4 = '93.184.216.34';
|
||||
const publicV6 = '2606:4700:4700::1111';
|
||||
const policy = {
|
||||
allowPrivateNetworkTargets: false,
|
||||
resolveHostname: async () => [publicV4],
|
||||
};
|
||||
const blocked = [
|
||||
'0.0.0.0',
|
||||
'10.1.2.3',
|
||||
'100.64.0.1',
|
||||
'127.0.0.1',
|
||||
'169.254.169.254',
|
||||
'172.16.0.1',
|
||||
'192.168.0.1',
|
||||
'192.0.2.1',
|
||||
'198.18.0.1',
|
||||
'198.51.100.1',
|
||||
'203.0.113.1',
|
||||
'224.0.0.1',
|
||||
'255.255.255.255',
|
||||
'::',
|
||||
'::1',
|
||||
'fc00::1',
|
||||
'fd12::1',
|
||||
'fe80::1',
|
||||
'febf::1',
|
||||
'ff02::1',
|
||||
'::ffff:127.0.0.1',
|
||||
'::ffff:7f00:1',
|
||||
'::ffff:a00:1',
|
||||
'0:0:0:0:0:ffff:a9fe:a9fe',
|
||||
'64:ff9b::a00:1',
|
||||
'2001:db8::1',
|
||||
'2001::1',
|
||||
'2002:7f00:1::',
|
||||
'3fff::1',
|
||||
];
|
||||
|
||||
describe('provider URL policy', () => {
|
||||
it.each(blocked)('rejects literal and DNS answer %s', async (address) => {
|
||||
const url = `http://${address.includes(':') ? `[${address}]` : address}/`;
|
||||
await expect(validateProviderUrl(url, policy)).resolves.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
await expect(
|
||||
validateProviderUrl('https://provider.example', {
|
||||
...policy,
|
||||
resolveHostname: async () => [publicV4, address],
|
||||
})
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
});
|
||||
it.each([
|
||||
'ftp://host/',
|
||||
'file:///etc/passwd',
|
||||
'data:text/plain,hello',
|
||||
'http://user:secret@host',
|
||||
'invalid',
|
||||
'http://localhost.',
|
||||
'http://sub.localhost',
|
||||
])('rejects %s', async (url) => {
|
||||
await expect(validateProviderUrl(url, policy)).resolves.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
[],
|
||||
['bad-address'],
|
||||
[publicV4, 'host.example'],
|
||||
['127.0.0.1%zone'],
|
||||
])(
|
||||
'rejects malformed DNS records %j even with LAN opt-in',
|
||||
async (...addresses) => {
|
||||
// Jest spreads array table rows; collect the records back into a list.
|
||||
for (const allowPrivateNetworkTargets of [false, true]) {
|
||||
await expect(
|
||||
validateProviderUrl('https://provider.example', {
|
||||
allowPrivateNetworkTargets,
|
||||
resolveHostname: async () => addresses as string[],
|
||||
})
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
}
|
||||
}
|
||||
);
|
||||
it.each([publicV4, publicV6, '::ffff:5db8:d822'])(
|
||||
'accepts public address %s and retains the hostname',
|
||||
async (address) => {
|
||||
const result = await validateProviderUrl(
|
||||
'https://provider.example/path',
|
||||
{
|
||||
...policy,
|
||||
resolveHostname: async () => [address],
|
||||
}
|
||||
);
|
||||
expect(result).toEqual({
|
||||
url: new URL('https://provider.example/path'),
|
||||
addresses: [address],
|
||||
});
|
||||
}
|
||||
);
|
||||
it('resolves and pins trusted LAN hosts while still rejecting schemes and credentials', async () => {
|
||||
const trusted = {
|
||||
allowPrivateNetworkTargets: true,
|
||||
resolveHostname: async () => ['127.0.0.1', '::1'],
|
||||
};
|
||||
await expect(
|
||||
validateProviderUrl('https://lan.example', trusted)
|
||||
).resolves.toMatchObject({ addresses: ['127.0.0.1', '::1'] });
|
||||
await expect(
|
||||
validateProviderUrl('https://user:secret@lan.example', trusted)
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
validateProviderUrl('file:///tmp/test', trusted)
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
|
||||
export interface ProviderUrlPolicy {
|
||||
readonly allowPrivateNetworkTargets: boolean;
|
||||
readonly resolveHostname: (hostname: string) => Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
export interface ProviderUrlError {
|
||||
readonly message: string;
|
||||
readonly status: number;
|
||||
readonly lookupError?: unknown;
|
||||
}
|
||||
|
||||
export interface ValidatedProviderTarget {
|
||||
readonly url: URL;
|
||||
readonly addresses: readonly string[];
|
||||
}
|
||||
|
||||
export function providerUrlErrorBody(error: ProviderUrlError): {
|
||||
message: string;
|
||||
status: number;
|
||||
} {
|
||||
return { message: error.message, status: error.status };
|
||||
}
|
||||
|
||||
export async function resolveHostname(
|
||||
hostname: string
|
||||
): Promise<readonly string[]> {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||
return records.map((record) => record.address);
|
||||
}
|
||||
|
||||
export async function validateProviderUrl(
|
||||
rawUrl: string,
|
||||
policy: ProviderUrlPolicy
|
||||
): Promise<ValidatedProviderTarget | ProviderUrlError> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return { message: 'Provider URL is not a valid URL', status: 400 };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return {
|
||||
message: 'Only http and https provider URLs are supported',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return {
|
||||
message: 'Provider URL credentials are not supported',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const local = hostname.replace(/\.$/, '');
|
||||
if (
|
||||
!policy.allowPrivateNetworkTargets &&
|
||||
(local === 'localhost' ||
|
||||
local.endsWith('.localhost') ||
|
||||
(isIP(hostname) !== 0 && !isPublicAddress(hostname)))
|
||||
) {
|
||||
return privateAddressError();
|
||||
}
|
||||
let addresses: readonly string[];
|
||||
try {
|
||||
addresses = isIP(hostname)
|
||||
? [hostname]
|
||||
: await policy.resolveHostname(hostname);
|
||||
} catch (lookupError) {
|
||||
return {
|
||||
message: 'Provider URL host could not be resolved',
|
||||
status: 400,
|
||||
lookupError,
|
||||
};
|
||||
}
|
||||
// Validate every answer; never let a malformed record trigger a second DNS
|
||||
// lookup in the transport. Even trusted LAN mode requires concrete IPs.
|
||||
if (!addresses.length || addresses.some((address) => !isIP(address))) {
|
||||
return {
|
||||
message: 'Provider URL host could not be resolved',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
if (
|
||||
!policy.allowPrivateNetworkTargets &&
|
||||
addresses.some((address) => !isPublicAddress(address))
|
||||
) {
|
||||
return privateAddressError();
|
||||
}
|
||||
return { url, addresses: [...addresses] };
|
||||
}
|
||||
|
||||
function privateAddressError(): ProviderUrlError {
|
||||
return {
|
||||
message: 'Provider URL points to a private or local network address',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const blockedV4 = new BlockList();
|
||||
for (const [network, prefix] of [
|
||||
['0.0.0.0', 8],
|
||||
['10.0.0.0', 8],
|
||||
['100.64.0.0', 10],
|
||||
['127.0.0.0', 8],
|
||||
['169.254.0.0', 16],
|
||||
['172.16.0.0', 12],
|
||||
['192.0.0.0', 24],
|
||||
['192.0.2.0', 24],
|
||||
['192.88.99.0', 24],
|
||||
['192.168.0.0', 16],
|
||||
['198.18.0.0', 15],
|
||||
['198.51.100.0', 24],
|
||||
['203.0.113.0', 24],
|
||||
['224.0.0.0', 3],
|
||||
] as const)
|
||||
blockedV4.addSubnet(network, prefix, 'ipv4');
|
||||
|
||||
const globalV6 = new BlockList();
|
||||
globalV6.addSubnet('2000::', 3, 'ipv6');
|
||||
const blockedV6 = new BlockList();
|
||||
for (const [network, prefix] of [
|
||||
['2001::', 23], // Protocol assignments (including Teredo / benchmarking).
|
||||
['2001:db8::', 32],
|
||||
['2002::', 16], // Documentation / 6to4.
|
||||
['3fff::', 20], // Documentation.
|
||||
] as const)
|
||||
blockedV6.addSubnet(network, prefix, 'ipv6');
|
||||
const mappedV4 = new BlockList();
|
||||
mappedV4.addSubnet('::ffff:0:0', 96, 'ipv6');
|
||||
|
||||
function isPublicAddress(address: string): boolean {
|
||||
if (isIP(address) === 4) return !blockedV4.check(address, 'ipv4');
|
||||
// Node BlockList handles IPv4-mapped IPv6 in both dotted and hex forms.
|
||||
if (mappedV4.check(address, 'ipv6'))
|
||||
return !blockedV4.check(address, 'ipv6');
|
||||
return globalV6.check(address, 'ipv6') && !blockedV6.check(address, 'ipv6');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+U4KN4bcXoh+W
|
||||
TMyRIBLi2jMWmqv8ZB5rcyiVvcqp4Z8aJOOuiSZjMJJxxsVwu6qKLZwm8KThYo4M
|
||||
m5x5tXMOvUKhqxDoXhQcql5dAiydoFXCJD56cQnRpS/BT5PtqtSBG9VVo3v1YF13
|
||||
rKBF6ETcIiHFT7+jlSzPUP3lnyB7QL+yDFfaqX68TGXRkFG3thl3YMF+JhFILe7V
|
||||
s8H2yZAiI3HuIeBUdTHOqnqVFW2vH1Hl4soXOsEQlQF5VyAFbCJ44duDfCe5uSbX
|
||||
8lA+1VNJSRLKaQUdqvqgjF4UKmnk6a7xMAW0WTiC7RFoIUkH1WosprwEygYohhiF
|
||||
XoG6QbKhAgMBAAECggEAA/H6NlOz9mbzbauo3+dAzPgF8BWDtCclJEgOUtBM16mo
|
||||
ISQbnh4UsCCtIHOk2xngxp18a6g4Wr2uwR8mprU2rdsJew1vO8nbc96qNxZY82mD
|
||||
7ZLPwrz+HZzleQXbxKTyY7y+dth9NNBrD5SB/AD9EG0asxrcl5j7hU6h/LUIONXN
|
||||
GmOwEs7N7IsZgQtfItX8Zgo8+PUfQMp2QUawM4BAXwOSW3rpyioNmMQCa0Y/uKAf
|
||||
8CKvNcP2XcbKucZNc9KHpNtaAjq7PDJ+7L/mSP3MGGNlYRjaasPiIemo2C6Pg1Pb
|
||||
pr+yhwt28PFTqf2LLZcuivBtcPRlMmawcO9KFLjRewKBgQDm28ikYl8bqrROEgNQ
|
||||
QL5rY/DzaaFjyEwvj+qWXWzy6cykQpw4vdLIfe3Kn9Evc4dU1o//RYnoqIWi7EJ0
|
||||
Ma6OBp4lMocHajklZdCG+ArQENcPcdRgVWGf6LZtEbPGaTk88mDOEMm50/FfnKGW
|
||||
7FWsmpmcKqZduq2NX2BvzBDmtwKBgQDTDbTGIGelbe1Cl4ex/zBbcFA9CUm/4583
|
||||
1NMpfZNJaDbi9E3rOZK4ryCHAy4B9GQnmVW+A7ChzqMz2y9jKKM0RHlBXzvkroas
|
||||
IQpRip11Vb/6ZXH3rM+7zgRYHi61+L7de2YbtCUfEOxQ+DcE5yNg1WzgRlYh0yfQ
|
||||
SJ9f2XsZZwKBgEsVYn1sbShvbbMSkrdQR15gI+bXDSGJ7JVvhkmfWybqOZ+W9n5R
|
||||
5rNEmclUD1ISjgpeuni44jCkVsp1cuudmPsiVd8dPuN/fdSW96peFA412+xvBjbK
|
||||
rjS3GFYC8uhuIqqa3jdHKITi1NdW9wtCFF9N7PXovTEw3O9k/NV/lmOjAoGAWsDZ
|
||||
DB0hFHS5gloQYozeKWOZTTWyPc5OR76/cmbqL7WdbGgrHUvreHjt3sCSRwrlClYY
|
||||
FZYWnO1zJjhJHzV5QF91WJPv+DzH8jpe6oNVg//0hmKa6CqqRRKosY+A/ITS5gBK
|
||||
/vyuvbYUOBkT54rQnrIHmEUGgpL+2sRvq9Kj6V8CgYEAxJ46AcgAW0SriYSn+oMX
|
||||
KNMuAeFFS87fMEc+L3e/kH8ctWOGF8WAJew1v7TV+hHJvycMWqBbuvq7N1ArFBH4
|
||||
Hxq76pRoo6DMz5PxIVajSxj9+LuNWn9onuX9Ni0w2p9clH5nD1+Arnb2G91kZby8
|
||||
CqB+d4aWPHBq/0GQDp7xDR8=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDNDCCAhygAwIBAgIUL/DZdKkHvo0op+ZSNbwEJ8/q98UwDQYJKoZIhvcNAQEL
|
||||
BQAwGzEZMBcGA1UEAwwQcHJvdmlkZXIuZXhhbXBsZTAeFw0yNjA5MDYwNTU4Mjda
|
||||
Fw0zNjA5MDMwNTU4MjdaMBsxGTAXBgNVBAMMEHByb3ZpZGVyLmV4YW1wbGUwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+U4KN4bcXoh+WTMyRIBLi2jMW
|
||||
mqv8ZB5rcyiVvcqp4Z8aJOOuiSZjMJJxxsVwu6qKLZwm8KThYo4Mm5x5tXMOvUKh
|
||||
qxDoXhQcql5dAiydoFXCJD56cQnRpS/BT5PtqtSBG9VVo3v1YF13rKBF6ETcIiHF
|
||||
T7+jlSzPUP3lnyB7QL+yDFfaqX68TGXRkFG3thl3YMF+JhFILe7Vs8H2yZAiI3Hu
|
||||
IeBUdTHOqnqVFW2vH1Hl4soXOsEQlQF5VyAFbCJ44duDfCe5uSbX8lA+1VNJSRLK
|
||||
aQUdqvqgjF4UKmnk6a7xMAW0WTiC7RFoIUkH1WosprwEygYohhiFXoG6QbKhAgMB
|
||||
AAGjcDBuMB0GA1UdDgQWBBSiXrRLEeZImTh1I0IjfqRQoHIUfzAfBgNVHSMEGDAW
|
||||
gBSiXrRLEeZImTh1I0IjfqRQoHIUfzAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQU
|
||||
MBKCEHByb3ZpZGVyLmV4YW1wbGUwDQYJKoZIhvcNAQELBQADggEBAFtT545lqFbw
|
||||
3pgHsnFI2kpXOso1WhjJa44cliFt0L2rg9JgZCih27ba1OZmr55Y6X2lG2xmQtqF
|
||||
Gzm00pyC8EW/Yjwe2xcf0Tk2pGOTQ8MJlXhKOvwcxL6c6d/mc7Uvy+B/cwWbn9F9
|
||||
IeOgDiaPGTRWE/UgiVqq1ZARI3ExRN5G0QWejdw0Q3VYWh2L5lM9kKeRomudaX0T
|
||||
YKQFuuuZji9/RQTN9ZRfjVG0imJOMfqi0Nrl4eva4kHP1FWzH7yR5ybjsuG9WiXx
|
||||
6o7Ktfva+sRr//utJjluL8RvrxJ0d7Dd+BIKQQxBP3yL3YokweL1xzS2WdQM66QW
|
||||
N+E360kOO9k=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,323 @@
|
||||
import { classifyHostRequestFailure } from '@iptvnator/shared/host-health';
|
||||
import { ProviderRequestError } from './provider-request-error';
|
||||
import { ProviderAxiosTransport } from './provider-axios-transport';
|
||||
import express from 'express';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer, Agent as HttpsAgent } from 'node:https';
|
||||
import { Server } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { TLSSocket } from 'node:tls';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import {
|
||||
ValidatedHttpClient,
|
||||
WebBackendHttpClient,
|
||||
} from './validated-http-client';
|
||||
import { withServer } from './web-backend-app.spec-helpers';
|
||||
|
||||
const lan = {
|
||||
allowPrivateNetworkTargets: true,
|
||||
resolveHostname: async () => ['127.0.0.1'],
|
||||
};
|
||||
|
||||
async function withListeningServer<T>(
|
||||
server: Server,
|
||||
hostname: string,
|
||||
run: (port: number) => Promise<T>
|
||||
): Promise<T> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, hostname, resolve);
|
||||
});
|
||||
try {
|
||||
return await run((server.address() as AddressInfo).port);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('real axios validated transport', () => {
|
||||
it('uses exactly the resolved address at connect time, preserves Host, and bypasses ambient proxy settings', async () => {
|
||||
const proxy = express().use((_req, res) =>
|
||||
res.status(500).send('unexpected proxy')
|
||||
);
|
||||
await withServer(proxy, async (proxyUrl) => {
|
||||
const provider = express().use((req, res) =>
|
||||
res.json({ host: req.headers.host })
|
||||
);
|
||||
await withServer(provider, async (providerUrl) => {
|
||||
const { port } = new URL(providerUrl);
|
||||
const resolveHostname = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(['127.0.0.1'])
|
||||
.mockResolvedValue(['127.0.0.2']);
|
||||
const previous = process.env['http_proxy'];
|
||||
process.env['http_proxy'] = proxyUrl;
|
||||
try {
|
||||
const response = await new ValidatedHttpClient({
|
||||
...lan,
|
||||
resolveHostname,
|
||||
}).get(`http://provider.example:${port}/`);
|
||||
expect(response.data).toEqual({
|
||||
host: `provider.example:${port}`,
|
||||
});
|
||||
expect(resolveHostname).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (previous === undefined)
|
||||
delete process.env['http_proxy'];
|
||||
else process.env['http_proxy'] = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
it('uses the new validated DNS answer on each same-host redirect instead of a pooled socket', async () => {
|
||||
const provider = express().use((_req, res) => res.redirect('/next'));
|
||||
await withServer(provider, async (providerUrl) => {
|
||||
const { port } = new URL(providerUrl);
|
||||
const resolveHostname = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(['127.0.0.1'])
|
||||
.mockResolvedValueOnce(['127.0.0.2']);
|
||||
await expect(
|
||||
new ValidatedHttpClient({ ...lan, resolveHostname }).get(
|
||||
`http://provider.example:${port}/`,
|
||||
{ timeout: 500 }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
initialResponded: true,
|
||||
cause: {
|
||||
code: expect.stringMatching(
|
||||
/^(ECONNREFUSED|ECONNABORTED)$/
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(resolveHostname).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
it.each(['invalid-gzip', 'unfinished'])(
|
||||
'discards the %s redirect body immediately',
|
||||
async (kind) => {
|
||||
const provider = express();
|
||||
provider.get('/start', (_req, res) => {
|
||||
res.status(302).set('Location', '/final');
|
||||
if (kind === 'invalid-gzip')
|
||||
res.set('Content-Encoding', 'gzip').end('not gzip');
|
||||
else res.write('a body that never ends');
|
||||
});
|
||||
provider.get('/final', (_req, res) => res.json({ ok: true }));
|
||||
await withServer(provider, async (url) => {
|
||||
await expect(
|
||||
new ValidatedHttpClient(lan).get(`${url}/start`, {
|
||||
timeout: 500,
|
||||
})
|
||||
).resolves.toMatchObject({ data: { ok: true } });
|
||||
});
|
||||
}
|
||||
);
|
||||
it('preserves double-slash paths and escaped segments without changing connection authority', async () => {
|
||||
const provider = express().use((req, res) =>
|
||||
res.json({ path: req.url, host: req.headers.host })
|
||||
);
|
||||
await withServer(provider, async (url) => {
|
||||
const target = new URL(url);
|
||||
await expect(
|
||||
new ValidatedHttpClient(lan).get(
|
||||
`${url}//other.example/a%2Fb?token=a%2Bb`
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
path: '//other.example/a%2Fb?token=a%2Bb',
|
||||
host: target.host,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
it('keeps query serialization and sends only Location query on a real redirect', async () => {
|
||||
const requests: string[] = [];
|
||||
const provider = express().use((req, res) => {
|
||||
requests.push(req.url);
|
||||
if (requests.length === 1) res.redirect('/final?ticket=issued');
|
||||
else res.send('done');
|
||||
});
|
||||
await withServer(provider, async (url) => {
|
||||
await new ValidatedHttpClient(lan).get(`${url}/start?fixed=yes`, {
|
||||
params: { password: 'a b+c' },
|
||||
});
|
||||
expect(requests).toEqual([
|
||||
'/start?fixed=yes&password=a+b%2Bc',
|
||||
'/final?ticket=issued',
|
||||
]);
|
||||
});
|
||||
});
|
||||
it('retains final arraybuffer, decompression, BOM text and JSON behavior', async () => {
|
||||
const provider = express();
|
||||
provider.get('/binary', (_req, res) =>
|
||||
res.end(Buffer.from([0, 255, 128]))
|
||||
);
|
||||
provider.get('/compressed', (_req, res) =>
|
||||
res.set('Content-Encoding', 'gzip').end(gzipSync('{"ok":true}'))
|
||||
);
|
||||
provider.get('/text', (_req, res) => res.end('\uFEFFhello'));
|
||||
await withServer(provider, async (url) => {
|
||||
const client = new ValidatedHttpClient(lan);
|
||||
expect(
|
||||
(
|
||||
await client.get(`${url}/binary`, {
|
||||
responseType: 'arraybuffer',
|
||||
})
|
||||
).data
|
||||
).toEqual(Buffer.from([0, 255, 128]));
|
||||
expect((await client.get(`${url}/compressed`)).data).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect((await client.get(`${url}/text`)).data).toEqual('hello');
|
||||
});
|
||||
});
|
||||
it('cancels an unfinished final body and closes its connection', async () => {
|
||||
const controller = new AbortController();
|
||||
let started!: () => void;
|
||||
const bodyStarted = new Promise<void>((resolve) => (started = resolve));
|
||||
const provider = express().use((_req, res) => {
|
||||
res.write('unfinished');
|
||||
started();
|
||||
});
|
||||
await withServer(provider, async (url) => {
|
||||
const pending = new ValidatedHttpClient(lan).get(url, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await bodyStarted;
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
cause: { code: 'ERR_CANCELED' },
|
||||
});
|
||||
});
|
||||
});
|
||||
it.each(['truncated', 'invalid-gzip', 'timeout'])(
|
||||
'retains response evidence for a %s final body',
|
||||
async (kind) => {
|
||||
const provider = express().use((_req, res) => {
|
||||
if (kind === 'invalid-gzip') {
|
||||
res.set('Content-Encoding', 'gzip').end('not gzip');
|
||||
return;
|
||||
}
|
||||
res.set('Content-Length', '100').write('short');
|
||||
if (kind === 'truncated') setTimeout(() => res.destroy(), 10);
|
||||
});
|
||||
await withServer(provider, async (url) => {
|
||||
const error = await new ValidatedHttpClient(lan)
|
||||
.get(url, { timeout: 100 })
|
||||
.catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(ProviderRequestError);
|
||||
const failure = error as ProviderRequestError;
|
||||
expect(failure.cause).toMatchObject({
|
||||
response: { status: 200, statusText: 'OK' },
|
||||
});
|
||||
expect(classifyHostRequestFailure(failure.cause)).toBe(
|
||||
'responded'
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
it('times out before headers on the custom native transport', async () => {
|
||||
const provider = express().use(() => {
|
||||
/* Deliberately silent synthetic provider. */
|
||||
});
|
||||
await withServer(provider, async (url) => {
|
||||
await expect(
|
||||
new ValidatedHttpClient(lan).get(url, { timeout: 100 })
|
||||
).rejects.toMatchObject({
|
||||
initialResponded: false,
|
||||
cause: { code: 'ECONNABORTED' },
|
||||
});
|
||||
});
|
||||
});
|
||||
it('does not cap a healthy trickling body at the inactivity timeout', async () => {
|
||||
const provider = express().use((_req, res) => {
|
||||
let count = 0;
|
||||
res.write('start');
|
||||
const interval = setInterval(() => {
|
||||
res.write('x');
|
||||
if (++count === 8) {
|
||||
clearInterval(interval);
|
||||
res.end();
|
||||
}
|
||||
}, 25);
|
||||
res.on('close', () => clearInterval(interval));
|
||||
});
|
||||
await withServer(provider, async (url) => {
|
||||
await expect(
|
||||
new ValidatedHttpClient(lan).get(url, { timeout: 100 })
|
||||
).resolves.toMatchObject({ data: 'startxxxxxxxx' });
|
||||
});
|
||||
});
|
||||
it('preserves TLS SNI and hostname verification with a pinned address', async () => {
|
||||
// This key/certificate is exclusively a synthetic test fixture.
|
||||
const cert = readFileSync(`${__dirname}/testing/provider-test.pem`);
|
||||
const key = readFileSync(`${__dirname}/testing/provider-test.key`);
|
||||
let requests = 0;
|
||||
const server = createServer({ key, cert }, (req, res) => {
|
||||
requests++;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
host: req.headers.host,
|
||||
sni: (req.socket as TLSSocket & { servername: string })
|
||||
.servername,
|
||||
})
|
||||
);
|
||||
});
|
||||
const trustedTransport: WebBackendHttpClient = {
|
||||
get: (url, options) => {
|
||||
const agent = options?.httpsAgent as HttpsAgent & {
|
||||
options: { ca?: Buffer };
|
||||
};
|
||||
// Supply the local test CA, retaining the production lookup,
|
||||
// SNI and rejectUnauthorized defaults on the actual agent.
|
||||
agent.options.ca = cert;
|
||||
return new ProviderAxiosTransport().get(url, options);
|
||||
},
|
||||
};
|
||||
await withListeningServer(server, '127.0.0.1', async (port) => {
|
||||
await expect(
|
||||
new ValidatedHttpClient(lan).get(
|
||||
`https://provider.example:${port}`
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
cause: { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' },
|
||||
});
|
||||
expect(requests).toBe(0);
|
||||
const client = new ValidatedHttpClient(lan, trustedTransport);
|
||||
await expect(
|
||||
client.get(`https://provider.example:${port}`)
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
host: `provider.example:${port}`,
|
||||
sni: 'provider.example',
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
client.get(`https://wrong.example:${port}`)
|
||||
).rejects.toMatchObject({
|
||||
cause: { code: 'ERR_TLS_CERT_ALTNAME_INVALID' },
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
it('connects to a pinned IPv6 address and an IPv6 literal in trusted LAN mode', async () => {
|
||||
const server = new Server((_req, res) => res.end('ipv6'));
|
||||
await withListeningServer(server, '::1', async (port) => {
|
||||
const client = new ValidatedHttpClient({
|
||||
...lan,
|
||||
resolveHostname: async () => ['::1'],
|
||||
});
|
||||
expect(
|
||||
(await client.get(`http://provider.example:${port}/`)).data
|
||||
).toBe('ipv6');
|
||||
expect((await client.get(`http://[::1]:${port}/`)).data).toBe(
|
||||
'ipv6'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Agent } from 'node:http';
|
||||
import { LookupFunction } from 'node:net';
|
||||
import { ValidatedHttpClient } from './validated-http-client';
|
||||
import {
|
||||
resolvePublicHost,
|
||||
StubHttpClient,
|
||||
} from './web-backend-app.spec-helpers';
|
||||
|
||||
const policy = {
|
||||
allowPrivateNetworkTargets: false,
|
||||
resolveHostname: resolvePublicHost,
|
||||
};
|
||||
|
||||
describe('validated HTTP redirect chain', () => {
|
||||
it.each([301, 302, 303, 307, 308])(
|
||||
'follows relative Location for %s, without replaying original params',
|
||||
async (status) => {
|
||||
const transport = new StubHttpClient();
|
||||
transport.queueRedirect('../next?ticket=provider', status);
|
||||
transport.queueResponse('done');
|
||||
const result = await new ValidatedHttpClient(policy, transport).get(
|
||||
'https://provider.example/base/start',
|
||||
{
|
||||
params: { username: 'demo', password: 'secret' },
|
||||
timeout: 1234,
|
||||
}
|
||||
);
|
||||
expect(result.data).toBe('done');
|
||||
expect(transport.requests[0].params).toEqual({
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
});
|
||||
expect(transport.requests[1]).toMatchObject({
|
||||
url: 'https://provider.example/next?ticket=provider',
|
||||
timeout: 1234,
|
||||
});
|
||||
expect(transport.requests[1].params).toBeUndefined();
|
||||
}
|
||||
);
|
||||
it('resolves fragment-only Location against the sent query and detects the cycle', async () => {
|
||||
const transport = new StubHttpClient();
|
||||
transport.queueRedirect('#fragment');
|
||||
await expect(
|
||||
new ValidatedHttpClient(policy, transport).get(
|
||||
'https://provider.example/start',
|
||||
{
|
||||
params: { token: 'secret' },
|
||||
}
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
policyError: { status: 502, message: 'Redirect cycle detected' },
|
||||
});
|
||||
expect(transport.requests).toHaveLength(1);
|
||||
});
|
||||
it.each([
|
||||
['https://provider.example/next', true],
|
||||
['https://provider.example:8443/next', false],
|
||||
['http://provider.example/next', false],
|
||||
['https://other.example/next', false],
|
||||
])(
|
||||
'scopes sensitive headers on redirect to %s',
|
||||
async (location, retained) => {
|
||||
const transport = new StubHttpClient();
|
||||
transport.queueRedirect(location as string);
|
||||
transport.queueResponse('done');
|
||||
const headers = {
|
||||
Authorization: 'Bearer secret',
|
||||
cOoKiE: 'mac=secret',
|
||||
'Proxy-Authorization': 'secret',
|
||||
sN: 'serial',
|
||||
'User-Agent': 'player',
|
||||
};
|
||||
await new ValidatedHttpClient(policy, transport).get(
|
||||
'https://provider.example/start',
|
||||
{ headers }
|
||||
);
|
||||
expect(transport.requests[1].headers).toEqual(
|
||||
retained ? headers : { 'User-Agent': 'player' }
|
||||
);
|
||||
expect(headers.Authorization).toBe('Bearer secret');
|
||||
}
|
||||
);
|
||||
it.each([
|
||||
'http://127.0.0.1/',
|
||||
'https://user:secret@provider.example/',
|
||||
'file:///tmp/test',
|
||||
'http://[invalid',
|
||||
])(
|
||||
'refuses unsafe Location %s without another transport call',
|
||||
async (location) => {
|
||||
const transport = new StubHttpClient();
|
||||
transport.queueRedirect(location);
|
||||
await expect(
|
||||
new ValidatedHttpClient(policy, transport).get(
|
||||
'https://provider.example/start'
|
||||
)
|
||||
).rejects.toMatchObject({ initialResponded: true });
|
||||
expect(transport.requests).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
it('accepts five redirects and rejects a sixth before its destination', async () => {
|
||||
for (const count of [5, 6]) {
|
||||
const transport = new StubHttpClient();
|
||||
for (let i = 0; i < count; i++) transport.queueRedirect(`/hop${i}`);
|
||||
transport.queueResponse('done');
|
||||
const request = new ValidatedHttpClient(policy, transport).get(
|
||||
'https://provider.example/start'
|
||||
);
|
||||
if (count === 5)
|
||||
await expect(request).resolves.toMatchObject({ data: 'done' });
|
||||
else
|
||||
await expect(request).rejects.toMatchObject({
|
||||
policyError: { status: 502, message: 'Too many redirects' },
|
||||
});
|
||||
expect(transport.requests).toHaveLength(6);
|
||||
}
|
||||
});
|
||||
it('rejects a redirect without Location', async () => {
|
||||
const transport = new StubHttpClient();
|
||||
transport.queueRedirect('');
|
||||
await expect(
|
||||
new ValidatedHttpClient(policy, transport).get(
|
||||
'https://provider.example'
|
||||
)
|
||||
).rejects.toMatchObject({ policyError: { status: 502 } });
|
||||
});
|
||||
it('revalidates DNS on same-host hops and blocks a changed answer before connect', async () => {
|
||||
const transport = new StubHttpClient();
|
||||
transport.queueRedirect('/next');
|
||||
const resolveHostname = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(['93.184.216.34'])
|
||||
.mockResolvedValueOnce(['127.0.0.1']);
|
||||
await expect(
|
||||
new ValidatedHttpClient(
|
||||
{ ...policy, resolveHostname },
|
||||
transport
|
||||
).get('https://provider.example/start')
|
||||
).rejects.toMatchObject({
|
||||
policyError: { status: 400 },
|
||||
initialResponded: true,
|
||||
});
|
||||
expect(resolveHostname).toHaveBeenCalledTimes(2);
|
||||
expect(transport.requests).toHaveLength(1);
|
||||
});
|
||||
it('pins all validated IPv4/IPv6 records without a second DNS lookup', async () => {
|
||||
const transport = new StubHttpClient();
|
||||
transport.queueResponse('done');
|
||||
const get = jest.spyOn(transport, 'get');
|
||||
const resolveHostname = jest
|
||||
.fn()
|
||||
.mockResolvedValue(['93.184.216.34', '2606:4700:4700::1111']);
|
||||
await new ValidatedHttpClient(
|
||||
{ ...policy, resolveHostname },
|
||||
transport
|
||||
).get('https://provider.example/start');
|
||||
const options = get.mock.calls[0][1];
|
||||
if (!options) throw new Error('Missing transport options');
|
||||
expect(options).toMatchObject({
|
||||
maxRedirects: 0,
|
||||
proxy: false,
|
||||
adapter: 'http',
|
||||
});
|
||||
const agent = options.httpsAgent as Agent & {
|
||||
options: { lookup: LookupFunction; proxyEnv: object };
|
||||
};
|
||||
const lookup = agent.options.lookup as LookupFunction;
|
||||
const callback = jest.fn();
|
||||
lookup('provider.example', { all: true }, callback);
|
||||
expect(callback).toHaveBeenLastCalledWith(null, [
|
||||
{ address: '93.184.216.34', family: 4 },
|
||||
{ address: '2606:4700:4700::1111', family: 6 },
|
||||
]);
|
||||
lookup('provider.example', { family: 6 }, callback);
|
||||
expect(callback).toHaveBeenLastCalledWith(
|
||||
null,
|
||||
'2606:4700:4700::1111',
|
||||
6
|
||||
);
|
||||
expect(resolveHostname).toHaveBeenCalledTimes(1);
|
||||
expect(agent.options).toMatchObject({ proxyEnv: {} });
|
||||
});
|
||||
it('does not send a hop when canceled during its DNS validation', async () => {
|
||||
const controller = new AbortController();
|
||||
const transport = new StubHttpClient();
|
||||
const resolveHostname = async () => {
|
||||
controller.abort();
|
||||
return ['93.184.216.34'];
|
||||
};
|
||||
await expect(
|
||||
new ValidatedHttpClient(
|
||||
{ ...policy, resolveHostname },
|
||||
transport
|
||||
).get('https://provider.example', { signal: controller.signal })
|
||||
).rejects.toMatchObject({ initialResponded: false });
|
||||
expect(transport.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ProviderAxiosTransport } from './provider-axios-transport';
|
||||
import {
|
||||
discardProviderBody,
|
||||
discardProviderErrorBody,
|
||||
readProviderBody,
|
||||
} from './provider-response';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { Agent as HttpAgent, ClientRequest } from 'node:http';
|
||||
import { Agent as HttpsAgent } from 'node:https';
|
||||
import { isIP, LookupFunction } from 'node:net';
|
||||
import { ProviderRequestError } from './provider-request-error';
|
||||
import { ProviderUrlPolicy, validateProviderUrl } from './provider-url-policy';
|
||||
|
||||
export interface WebBackendHttpGetOptions {
|
||||
readonly headers?: Record<string, string>;
|
||||
readonly params?: Record<string, string>;
|
||||
readonly responseType?: 'arraybuffer';
|
||||
readonly timeout?: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type ProviderTransportOptions = Omit<
|
||||
WebBackendHttpGetOptions,
|
||||
'responseType'
|
||||
> &
|
||||
Pick<
|
||||
AxiosRequestConfig,
|
||||
| 'responseType'
|
||||
| 'maxRedirects'
|
||||
| 'validateStatus'
|
||||
| 'httpAgent'
|
||||
| 'httpsAgent'
|
||||
| 'proxy'
|
||||
| 'adapter'
|
||||
>;
|
||||
|
||||
export interface WebBackendHttpResponse<T> {
|
||||
readonly data: T;
|
||||
readonly status: number;
|
||||
readonly statusText?: string;
|
||||
readonly request?: ClientRequest;
|
||||
readonly headers: { readonly location?: string };
|
||||
}
|
||||
|
||||
/** Injected transports must honor the same options and status contract as axios. */
|
||||
export interface WebBackendHttpClient {
|
||||
get<T>(
|
||||
url: string,
|
||||
options?: ProviderTransportOptions
|
||||
): Promise<WebBackendHttpResponse<T>>;
|
||||
}
|
||||
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const SENSITIVE_HEADERS = new Set([
|
||||
'authorization',
|
||||
'cookie',
|
||||
'proxy-authorization',
|
||||
'sn',
|
||||
]);
|
||||
|
||||
export class ValidatedHttpClient implements WebBackendHttpClient {
|
||||
constructor(
|
||||
private readonly policy: ProviderUrlPolicy,
|
||||
private readonly transport: WebBackendHttpClient = new ProviderAxiosTransport()
|
||||
) {}
|
||||
|
||||
async get<T>(
|
||||
rawUrl: string,
|
||||
options: WebBackendHttpGetOptions = {}
|
||||
): Promise<WebBackendHttpResponse<T>> {
|
||||
let currentUrl = rawUrl;
|
||||
let params = options.params;
|
||||
let headers = options.headers;
|
||||
let initialResponded = false;
|
||||
const visited = new Set<string>();
|
||||
try {
|
||||
for (let redirects = 0; ; redirects++) {
|
||||
options.signal?.throwIfAborted();
|
||||
const target = await validateProviderUrl(
|
||||
currentUrl,
|
||||
this.policy
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
if ('message' in target) {
|
||||
throw new ProviderRequestError(
|
||||
initialResponded,
|
||||
target.lookupError,
|
||||
target
|
||||
);
|
||||
}
|
||||
// Match axios serialization once. Location is resolved against
|
||||
// the URL actually sent; original params are never replayed.
|
||||
const sentUrl = new URL(
|
||||
axios.getUri({ url: target.url.href, params })
|
||||
);
|
||||
sentUrl.hash = '';
|
||||
if (visited.has(sentUrl.href))
|
||||
throw redirectError('Redirect cycle detected');
|
||||
visited.add(sentUrl.href);
|
||||
const lookup = pinnedLookup(target.addresses);
|
||||
// Fresh agents prevent socket-pool reuse across validations.
|
||||
// Empty proxyEnv also disables Node's native env proxy support.
|
||||
const agentOptions = { lookup, proxyEnv: {} };
|
||||
const httpAgent = new HttpAgent(agentOptions);
|
||||
const httpsAgent = new HttpsAgent(agentOptions);
|
||||
let response: WebBackendHttpResponse<T>;
|
||||
try {
|
||||
response = await this.transport.get<T>(target.url.href, {
|
||||
...options,
|
||||
headers,
|
||||
params,
|
||||
adapter: 'http',
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
responseType: 'stream',
|
||||
httpAgent,
|
||||
httpsAgent,
|
||||
validateStatus: (status) =>
|
||||
REDIRECT_STATUSES.has(status) ||
|
||||
(status >= 200 && status < 300),
|
||||
});
|
||||
if (!REDIRECT_STATUSES.has(response.status)) {
|
||||
try {
|
||||
return {
|
||||
...response,
|
||||
data: await readProviderBody(
|
||||
response.data,
|
||||
options.responseType === 'arraybuffer',
|
||||
options.timeout,
|
||||
response.request?.socket ?? undefined
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) throw error;
|
||||
// A broken/stalled body still proves the endpoint
|
||||
// answered. Preserve buffered axios error semantics.
|
||||
throw Object.assign(
|
||||
new Error('Provider response body failed'),
|
||||
{
|
||||
cause: error,
|
||||
response: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
initialResponded = true;
|
||||
discardProviderBody(response.data);
|
||||
} catch (error) {
|
||||
discardProviderErrorBody(error);
|
||||
throw error;
|
||||
} finally {
|
||||
httpAgent.destroy();
|
||||
httpsAgent.destroy();
|
||||
}
|
||||
if (redirects >= 5) throw redirectError('Too many redirects');
|
||||
const location = response.headers.location;
|
||||
if (!location)
|
||||
throw redirectError(
|
||||
'Redirect response did not include a location'
|
||||
);
|
||||
let nextUrl: URL;
|
||||
try {
|
||||
nextUrl = new URL(location, sentUrl);
|
||||
} catch {
|
||||
throw redirectError('Redirect location is not a valid URL');
|
||||
}
|
||||
if (nextUrl.origin !== sentUrl.origin) {
|
||||
headers = Object.fromEntries(
|
||||
Object.entries(headers ?? {}).filter(
|
||||
([name]) =>
|
||||
!SENSITIVE_HEADERS.has(name.toLowerCase()) &&
|
||||
name.toLowerCase() !== 'host'
|
||||
)
|
||||
);
|
||||
}
|
||||
params = undefined;
|
||||
currentUrl = nextUrl.href;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderRequestError) throw error;
|
||||
throw new ProviderRequestError(initialResponded, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function redirectError(message: string): ProviderRequestError {
|
||||
return new ProviderRequestError(true, undefined, { message, status: 502 });
|
||||
}
|
||||
|
||||
function pinnedLookup(addresses: readonly string[]): LookupFunction {
|
||||
const records = addresses.map((address) => ({
|
||||
address,
|
||||
family: isIP(address),
|
||||
}));
|
||||
return (_hostname, options, callback) => {
|
||||
const eligible = options.family
|
||||
? records.filter((record) => record.family === options.family)
|
||||
: records;
|
||||
if (!eligible.length) {
|
||||
callback(
|
||||
Object.assign(
|
||||
new Error('No validated address for the requested family'),
|
||||
{ code: 'ENOTFOUND' }
|
||||
),
|
||||
[]
|
||||
);
|
||||
} else if (options.all) {
|
||||
callback(null, eligible);
|
||||
} else {
|
||||
callback(null, eligible[0].address, eligible[0].family);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -418,7 +418,7 @@ describe('web backend host connectivity guard', () => {
|
||||
// not consume the single trial the breaker allows.
|
||||
resolvable = false;
|
||||
const refusedByPolicy = await call();
|
||||
expect(refusedByPolicy.status).toBe(400);
|
||||
expect(refusedByPolicy.status).toBe(200);
|
||||
|
||||
resolvable = true;
|
||||
const trial = await call();
|
||||
@@ -469,7 +469,7 @@ describe('web backend host connectivity guard', () => {
|
||||
);
|
||||
|
||||
const first = await call();
|
||||
expect(first.status).toBe(400);
|
||||
expect(first.status).toBe(200);
|
||||
// The DNS error is internal: the client sees what it always saw.
|
||||
await expect(first.json()).resolves.toEqual({
|
||||
message: 'Provider URL host could not be resolved',
|
||||
@@ -492,21 +492,11 @@ describe('web backend host connectivity guard', () => {
|
||||
});
|
||||
|
||||
it('does not fast-fail a provider whose redirect destination is dead', async () => {
|
||||
// The shape this route actually produces, verified against axios
|
||||
// 1.19.0: follow-redirects walks the chain inside one `get()`, so
|
||||
// `config` still holds the URL we asked for and only
|
||||
// `request._currentUrl` names the hop that failed. Reading `config.url`
|
||||
// alone would compare the original URL with itself, find no redirect,
|
||||
// and charge the dead destination to the provider that answered.
|
||||
const redirectedFailure = () =>
|
||||
Object.assign(new Error('connect ECONNREFUSED'), {
|
||||
code: 'ECONNREFUSED',
|
||||
config: { url: 'http://xtream.example/player_api.php' },
|
||||
request: { _currentUrl: 'http://cdn.dead.example/stream' },
|
||||
});
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueNetworkError(redirectedFailure());
|
||||
httpClient.queueNetworkError(redirectedFailure());
|
||||
for (let i = 0; i < 2; i++) {
|
||||
httpClient.queueRedirect('http://cdn.dead.example/stream');
|
||||
httpClient.queueNetworkError(hostLevelFailure());
|
||||
}
|
||||
httpClient.queueResponse({ user_info: { username: 'demo' } });
|
||||
const { guard } = createTestGuard();
|
||||
|
||||
@@ -534,7 +524,7 @@ describe('web backend host connectivity guard', () => {
|
||||
action: 'get_account_info',
|
||||
payload: { user_info: { username: 'demo' } },
|
||||
});
|
||||
expect(httpClient.requests).toHaveLength(3);
|
||||
expect(httpClient.requests).toHaveLength(5);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -689,31 +679,32 @@ describe('web backend host connectivity guard', () => {
|
||||
const started = new Promise<void>((resolve) => {
|
||||
arrived = resolve;
|
||||
});
|
||||
const transport = jest
|
||||
.spyOn(httpClient, 'get')
|
||||
.mockImplementationOnce(async () => {
|
||||
arrived();
|
||||
await pending;
|
||||
if (outcome !== 'success') {
|
||||
throw Object.assign(
|
||||
hostLevelFailure(
|
||||
outcome === 'cancel'
|
||||
? 'ERR_CANCELED'
|
||||
: 'ETIMEDOUT'
|
||||
),
|
||||
{
|
||||
request: {
|
||||
_currentUrl:
|
||||
outcome ===
|
||||
'redirect-failure'
|
||||
? 'http://cdn.example/slow'
|
||||
: `http://portal.example/${route === 'xtream' ? 'player_api.php' : ''}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return { data: [] as never };
|
||||
});
|
||||
const transport = jest.spyOn(httpClient, 'get');
|
||||
if (outcome === 'redirect-failure') {
|
||||
transport.mockImplementationOnce(async () => ({
|
||||
data: '' as never,
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'http://cdn.example/slow',
|
||||
},
|
||||
}));
|
||||
}
|
||||
transport.mockImplementationOnce(async () => {
|
||||
arrived();
|
||||
await pending;
|
||||
if (outcome !== 'success') {
|
||||
throw hostLevelFailure(
|
||||
outcome === 'cancel'
|
||||
? 'ERR_CANCELED'
|
||||
: 'ETIMEDOUT'
|
||||
);
|
||||
}
|
||||
return {
|
||||
data: [] as never,
|
||||
status: 200,
|
||||
headers: {},
|
||||
};
|
||||
});
|
||||
const trial = call();
|
||||
try {
|
||||
await started;
|
||||
@@ -729,7 +720,9 @@ describe('web backend host connectivity guard', () => {
|
||||
).message
|
||||
)
|
||||
).toBe(true);
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
expect(transport).toHaveBeenCalledTimes(
|
||||
outcome === 'redirect-failure' ? 2 : 1
|
||||
);
|
||||
} finally {
|
||||
settle();
|
||||
await trial;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
HostConnectivityGuard,
|
||||
OPEN_DURATION_MS,
|
||||
} from '@iptvnator/shared/host-health';
|
||||
import { isHostConnectivityFastFailMessage } from '@iptvnator/shared/interfaces';
|
||||
import axios from 'axios';
|
||||
import express from 'express';
|
||||
import { createWebBackendApp, WebBackendHttpClient } from './web-backend-app';
|
||||
import {
|
||||
registerProviderTarget,
|
||||
resolvePublicHost,
|
||||
StubHttpClient,
|
||||
withServer,
|
||||
} from './web-backend-app.spec-helpers';
|
||||
|
||||
describe('provider proxy redirect boundary', () => {
|
||||
it.each(['/xtream', '/stalker', '/parse', '/parse-xml'])(
|
||||
'%s refuses a private redirect before contacting its destination',
|
||||
async (route) => {
|
||||
let destinationRequests = 0;
|
||||
const destination = express().get('/private', (_req, res) => {
|
||||
destinationRequests++;
|
||||
res.send('synthetic private response');
|
||||
});
|
||||
await withServer(destination, async (destinationUrl) => {
|
||||
const provider = express().use((_req, res) => {
|
||||
res.redirect(`${destinationUrl}/private`);
|
||||
});
|
||||
await withServer(provider, async (providerUrl) => {
|
||||
// Only the first public endpoint is mapped to a synthetic
|
||||
// local provider. Axios and its redirect transport are real.
|
||||
const httpClient: WebBackendHttpClient = {
|
||||
get: (url, options) =>
|
||||
axios.get(
|
||||
new URL(url).hostname === 'provider.example'
|
||||
? providerUrl
|
||||
: url,
|
||||
options
|
||||
),
|
||||
};
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient,
|
||||
resolveHostname: resolvePublicHost,
|
||||
allowPrivateNetworkTargets: false,
|
||||
}),
|
||||
async (backend) => {
|
||||
const id = await registerProviderTarget(
|
||||
backend,
|
||||
'http://provider.example'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${backend}${route}?targetId=${id}`
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(destinationRequests).toBe(0);
|
||||
expect(response.status).toBe(
|
||||
route.startsWith('/parse') ? 400 : 200
|
||||
);
|
||||
expect(body).toEqual({
|
||||
status: 400,
|
||||
message:
|
||||
'Provider URL points to a private or local network address',
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('redirect routes and admission lifecycle', () => {
|
||||
it.each(['/xtream', '/stalker', '/parse', '/parse-xml'])(
|
||||
'%s follows an allowed local redirect and parses the final body',
|
||||
async (route) => {
|
||||
const provider = express().use((req, res) => {
|
||||
if (req.path !== '/final') {
|
||||
res.redirect('/final');
|
||||
return;
|
||||
}
|
||||
if (route === '/parse')
|
||||
res.send(
|
||||
'#EXTM3U\n#EXTINF:-1,Test\nhttps://example.com/test.ts'
|
||||
);
|
||||
else if (route === '/parse-xml')
|
||||
res.type('xml').send(
|
||||
'<?xml version="1.0"?><tv><channel id="test"><display-name>Test</display-name></channel></tv>'
|
||||
);
|
||||
else res.json({ ok: true });
|
||||
});
|
||||
await withServer(provider, async (url) => {
|
||||
await withServer(
|
||||
createWebBackendApp({ allowPrivateNetworkTargets: true }),
|
||||
async (backend) => {
|
||||
const id = await registerProviderTarget(backend, url);
|
||||
const response = await fetch(
|
||||
`${backend}${route}?targetId=${id}&action=test`
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
if (route === '/parse')
|
||||
expect(body).toMatchObject({ count: 1 });
|
||||
else if (route === '/parse-xml')
|
||||
expect(body).toHaveProperty('channels');
|
||||
else
|
||||
expect(body).toEqual({
|
||||
action: 'test',
|
||||
payload: { ok: true },
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
it.each(['/xtream', '/stalker', '/parse', '/parse-xml'])(
|
||||
'%s rechecks a registered hostname before its initial connection',
|
||||
async (route) => {
|
||||
const transport = new StubHttpClient();
|
||||
const resolveHostname = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(['93.184.216.34'])
|
||||
.mockResolvedValue(['127.0.0.1']);
|
||||
await withServer(
|
||||
createWebBackendApp({ httpClient: transport, resolveHostname }),
|
||||
async (backend) => {
|
||||
const id = await registerProviderTarget(
|
||||
backend,
|
||||
'https://provider.example'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${backend}${route}?targetId=${id}`
|
||||
);
|
||||
expect(response.status).toBe(
|
||||
route.startsWith('/parse') ? 400 : 200
|
||||
);
|
||||
expect(await response.json()).toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(transport.requests).toHaveLength(0);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
it.each(['/xtream', '/stalker'])(
|
||||
'%s keeps query-only redirect failures off the initial endpoint record',
|
||||
async (route) => {
|
||||
const transport = new StubHttpClient();
|
||||
const networkFailure = () =>
|
||||
Object.assign(
|
||||
new Error('secret http://user:password@provider.example'),
|
||||
{ code: 'ENOTFOUND' }
|
||||
);
|
||||
transport.queueNetworkError(networkFailure());
|
||||
transport.queueRedirect('?next=1');
|
||||
transport.queueNetworkError(networkFailure());
|
||||
transport.queueNetworkError(networkFailure());
|
||||
transport.queueResponse({ ok: true });
|
||||
const guard = new HostConnectivityGuard();
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient: transport,
|
||||
hostGuard: guard,
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (backend) => {
|
||||
const id = await registerProviderTarget(
|
||||
backend,
|
||||
'https://provider.example'
|
||||
);
|
||||
const call = () =>
|
||||
fetch(`${backend}${route}?targetId=${id}`);
|
||||
await call();
|
||||
const redirected = await call();
|
||||
expect(await redirected.text()).not.toContain('secret');
|
||||
await call();
|
||||
expect(await (await call()).json()).toMatchObject({
|
||||
payload: { ok: true },
|
||||
});
|
||||
expect(transport.requests).toHaveLength(5);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
it.each(['/xtream', '/stalker'])(
|
||||
'%s releases a half-open trial after redirect DNS refusal and counts initial DNS failures',
|
||||
async (route) => {
|
||||
let now = 1000;
|
||||
const guard = new HostConnectivityGuard({ now: () => now });
|
||||
const transport = new StubHttpClient();
|
||||
let failDns = false;
|
||||
const resolveHostname = async (hostname: string) => {
|
||||
if (failDns || hostname === 'blocked.example')
|
||||
throw Object.assign(new Error('secret transport details'), {
|
||||
code: 'ENOTFOUND',
|
||||
});
|
||||
return ['93.184.216.34'];
|
||||
};
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient: transport,
|
||||
hostGuard: guard,
|
||||
resolveHostname,
|
||||
}),
|
||||
async (backend) => {
|
||||
const id = await registerProviderTarget(
|
||||
backend,
|
||||
'https://provider.example'
|
||||
);
|
||||
const call = () =>
|
||||
fetch(`${backend}${route}?targetId=${id}`);
|
||||
failDns = true;
|
||||
await call();
|
||||
await call();
|
||||
const refused = (await (await call()).json()) as {
|
||||
message: string;
|
||||
};
|
||||
expect(
|
||||
isHostConnectivityFastFailMessage(refused.message)
|
||||
).toBe(true);
|
||||
expect(transport.requests).toHaveLength(0);
|
||||
now += OPEN_DURATION_MS + 1;
|
||||
failDns = false;
|
||||
transport.queueRedirect('https://blocked.example/next');
|
||||
const trial = await call();
|
||||
expect(await trial.json()).toEqual({
|
||||
status: 400,
|
||||
message: 'Provider URL host could not be resolved',
|
||||
});
|
||||
transport.queueResponse({ ok: true });
|
||||
expect(await (await call()).json()).toMatchObject({
|
||||
payload: { ok: true },
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -9,11 +9,11 @@
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { Server } from 'node:http';
|
||||
import { STALKER_MAG_USER_AGENT } from '@iptvnator/shared/interfaces';
|
||||
import { createWebBackendApp, WebBackendHttpClient } from './web-backend-app';
|
||||
import {
|
||||
createWebBackendApp,
|
||||
WebBackendHttpClient,
|
||||
WebBackendHttpGetOptions,
|
||||
} from './web-backend-app';
|
||||
ProviderTransportOptions,
|
||||
WebBackendHttpResponse,
|
||||
} from './validated-http-client';
|
||||
|
||||
/** The transport-identity headers every portal-facing Stalker request carries. */
|
||||
export const STALKER_IDENTITY_HEADERS = {
|
||||
@@ -41,12 +41,17 @@ export class StubHttpClient implements WebBackendHttpClient {
|
||||
readonly error?: Error;
|
||||
readonly status?: number;
|
||||
readonly statusText?: string;
|
||||
readonly headers?: { location?: string };
|
||||
}> = [];
|
||||
|
||||
queueResponse(data: unknown): void {
|
||||
this.queuedResponses.push({ data });
|
||||
}
|
||||
|
||||
queueRedirect(location: string, status = 302): void {
|
||||
this.queuedResponses.push({ data: '', status, headers: { location } });
|
||||
}
|
||||
|
||||
queueFailure(status: number, statusText = 'Provider failure'): void {
|
||||
this.queuedResponses.push({ data: null, status, statusText });
|
||||
}
|
||||
@@ -61,8 +66,8 @@ export class StubHttpClient implements WebBackendHttpClient {
|
||||
|
||||
async get<T>(
|
||||
url: string,
|
||||
options: WebBackendHttpGetOptions = {}
|
||||
): Promise<{ data: T }> {
|
||||
options: ProviderTransportOptions = {}
|
||||
): Promise<WebBackendHttpResponse<T>> {
|
||||
this.requests.push({
|
||||
headers: options.headers,
|
||||
params: options.params,
|
||||
@@ -90,7 +95,13 @@ export class StubHttpClient implements WebBackendHttpClient {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status) {
|
||||
if (
|
||||
response.status &&
|
||||
!(
|
||||
options.validateStatus ??
|
||||
((status) => status >= 200 && status < 300)
|
||||
)(response.status)
|
||||
) {
|
||||
const error = new Error(response.statusText) as Error & {
|
||||
response: { status: number; statusText: string };
|
||||
};
|
||||
@@ -101,7 +112,11 @@ export class StubHttpClient implements WebBackendHttpClient {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { data: response.data as T };
|
||||
return {
|
||||
data: response.data as T,
|
||||
status: response.status ?? 200,
|
||||
headers: response.headers ?? {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +162,7 @@ export async function withServer<T>(
|
||||
const address = server.address() as AddressInfo;
|
||||
return await callback(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
|
||||
@@ -893,7 +893,7 @@ https://stream.example/live.m3u8`);
|
||||
`${baseUrl}/xtream?targetId=${targetId}&action=get_account_info`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
message:
|
||||
'Provider URL points to a private or local network address',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import cors from 'cors';
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import zlib from 'node:zlib';
|
||||
import axios from 'axios';
|
||||
import epgParser from 'epg-parser';
|
||||
import parser from 'iptv-playlist-parser';
|
||||
import {
|
||||
@@ -33,19 +30,21 @@ import {
|
||||
ProviderError,
|
||||
} from './provider-error';
|
||||
|
||||
export interface WebBackendHttpGetOptions {
|
||||
readonly headers?: Record<string, string>;
|
||||
readonly params?: Record<string, string>;
|
||||
readonly responseType?: 'arraybuffer';
|
||||
readonly timeout?: number;
|
||||
}
|
||||
|
||||
export interface WebBackendHttpClient {
|
||||
get<T>(
|
||||
url: string,
|
||||
options?: WebBackendHttpGetOptions
|
||||
): Promise<{ data: T }>;
|
||||
}
|
||||
import {
|
||||
ValidatedHttpClient,
|
||||
WebBackendHttpClient,
|
||||
} from './validated-http-client';
|
||||
import { ProviderRequestError } from './provider-request-error';
|
||||
import {
|
||||
ProviderUrlPolicy,
|
||||
providerUrlErrorBody,
|
||||
resolveHostname,
|
||||
validateProviderUrl,
|
||||
} from './provider-url-policy';
|
||||
export type {
|
||||
WebBackendHttpClient,
|
||||
WebBackendHttpGetOptions,
|
||||
} from './validated-http-client';
|
||||
|
||||
interface PlaylistParseError {
|
||||
readonly message: string;
|
||||
@@ -68,42 +67,12 @@ export interface WebBackendAppOptions {
|
||||
readonly runtimeBackendUrl?: string;
|
||||
}
|
||||
|
||||
interface ProviderUrlPolicy {
|
||||
readonly allowPrivateNetworkTargets: boolean;
|
||||
readonly resolveHostname: (hostname: string) => Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
interface ProviderUrlError {
|
||||
readonly message: string;
|
||||
readonly status: number;
|
||||
/**
|
||||
* The DNS failure behind a "could not be resolved" refusal, when that is
|
||||
* what this is. Internal only — {@link providerUrlErrorBody} strips it,
|
||||
* because the client is told the same thing it always was.
|
||||
*
|
||||
* A name that does not resolve is evidence about reachability, exactly like
|
||||
* the `ENOTFOUND` the transport would have raised a moment later. Without
|
||||
* it, a host whose DNS died is refused by the policy on every request and
|
||||
* the breaker never opens, so each call keeps paying for the lookup.
|
||||
*/
|
||||
readonly lookupError?: unknown;
|
||||
}
|
||||
|
||||
/** The client-facing half of a {@link ProviderUrlError}. */
|
||||
function providerUrlErrorBody(error: ProviderUrlError): {
|
||||
message: string;
|
||||
status: number;
|
||||
} {
|
||||
return { message: error.message, status: error.status };
|
||||
}
|
||||
|
||||
type ProviderTargetRegistry = Map<string, URL>;
|
||||
|
||||
export function createWebBackendApp(
|
||||
options: WebBackendAppOptions = {}
|
||||
): Express {
|
||||
const app = express();
|
||||
const httpClient = (options.httpClient ?? axios) as WebBackendHttpClient;
|
||||
const guid = options.guid ?? createGuid;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const hostGuard =
|
||||
@@ -125,6 +94,10 @@ export function createWebBackendApp(
|
||||
isPrivateNetworkProxyAllowed(),
|
||||
resolveHostname: options.resolveHostname ?? resolveHostname,
|
||||
};
|
||||
const httpClient = new ValidatedHttpClient(
|
||||
providerUrlPolicy,
|
||||
options.httpClient
|
||||
);
|
||||
const providerTargets: ProviderTargetRegistry = new Map();
|
||||
|
||||
const corsMiddleware = cors({
|
||||
@@ -179,8 +152,8 @@ export function createWebBackendApp(
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = createProviderTargetId(result);
|
||||
providerTargets.set(targetId, result);
|
||||
const targetId = createProviderTargetId(result.url);
|
||||
providerTargets.set(targetId, result.url);
|
||||
res.json({ targetId });
|
||||
}
|
||||
);
|
||||
@@ -303,38 +276,10 @@ export function createWebBackendApp(
|
||||
}
|
||||
guardToken = admission.token;
|
||||
|
||||
const providerUrlError =
|
||||
await normalizeAndValidateXtreamProviderUrl(
|
||||
url,
|
||||
providerUrlPolicy
|
||||
);
|
||||
if (providerUrlError) {
|
||||
// Admitted, then abandoned before any request went out. A
|
||||
// policy refusal — private address, bad scheme — says nothing
|
||||
// about reachability, so it only hands the half-open slot back.
|
||||
// A name that would not resolve is different: that IS the host
|
||||
// failing to answer, and counting it is what lets the breaker
|
||||
// stop paying for the same dead lookup on every request.
|
||||
if (providerUrlError.lookupError !== undefined) {
|
||||
reportProviderRequestFailure(
|
||||
hostGuard,
|
||||
guardToken,
|
||||
providerUrlError.lookupError
|
||||
);
|
||||
} else {
|
||||
releaseProviderRequest(hostGuard, guardToken);
|
||||
}
|
||||
guardToken = null;
|
||||
res.status(providerUrlError.status).json(
|
||||
providerUrlErrorBody(providerUrlError)
|
||||
);
|
||||
return;
|
||||
}
|
||||
url.href = normalizeXtreamServerUrl(url.href);
|
||||
|
||||
requestUrl = appendPathSegment(url, 'player_api.php');
|
||||
|
||||
// Provider URLs are validated by /provider-targets before they enter the registry.
|
||||
// codeql[js/request-forgery]
|
||||
const response = await httpClient.get(requestUrl, {
|
||||
params: getProxyParams(req, ['targetId']),
|
||||
timeout: PROVIDER_REQUEST_TIMEOUT_MS.xtream,
|
||||
@@ -433,8 +378,6 @@ export function createWebBackendApp(
|
||||
guardToken = observeProviderRequest(hostGuard, requestUrl);
|
||||
}
|
||||
|
||||
// Provider URLs are validated by /provider-targets before they enter the registry.
|
||||
// codeql[js/request-forgery]
|
||||
const response = await httpClient.get(requestUrl, {
|
||||
headers,
|
||||
// `create_link` gets the longer budget: the portal mints a
|
||||
@@ -492,78 +435,6 @@ function getRegisteredProviderUrl(
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
async function validateProviderUrl(
|
||||
rawUrl: string,
|
||||
policy: ProviderUrlPolicy
|
||||
): Promise<URL | ProviderUrlError> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return { message: 'Provider URL is not a valid URL', status: 400 };
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return {
|
||||
message: 'Only http and https provider URLs are supported',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
return {
|
||||
message: 'Provider URL credentials are not supported',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
if (policy.allowPrivateNetworkTargets) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
if (isLocalHostname(hostname) || isPrivateOrReservedIp(hostname)) {
|
||||
return {
|
||||
message:
|
||||
'Provider URL points to a private or local network address',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
if (isIP(hostname) === 0) {
|
||||
let addresses: readonly string[];
|
||||
try {
|
||||
addresses = await policy.resolveHostname(hostname);
|
||||
} catch (lookupError) {
|
||||
return {
|
||||
message: 'Provider URL host could not be resolved',
|
||||
status: 400,
|
||||
lookupError,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
addresses.length === 0 ||
|
||||
addresses.some((address) =>
|
||||
isPrivateOrReservedIp(normalizeHostname(address))
|
||||
)
|
||||
) {
|
||||
return {
|
||||
message:
|
||||
'Provider URL points to a private or local network address',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function resolveHostname(hostname: string): Promise<readonly string[]> {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||
return records.map((record) => record.address);
|
||||
}
|
||||
|
||||
function createProviderTargetId(url: URL): string {
|
||||
return createHash('sha256').update(url.href).digest('hex');
|
||||
}
|
||||
@@ -636,29 +507,6 @@ function appendPathSegment(url: URL, segment: string): string {
|
||||
return nextUrl.href;
|
||||
}
|
||||
|
||||
async function normalizeAndValidateXtreamProviderUrl(
|
||||
url: URL,
|
||||
policy: ProviderUrlPolicy
|
||||
): Promise<ProviderUrlError | null> {
|
||||
let normalizedUrl: URL;
|
||||
try {
|
||||
normalizedUrl = new URL(normalizeXtreamServerUrl(url.href));
|
||||
} catch {
|
||||
return { message: 'Provider URL is not a valid URL', status: 400 };
|
||||
}
|
||||
|
||||
const validatedUrl = await validateProviderUrl(
|
||||
appendPathSegment(normalizedUrl, 'player_api.php'),
|
||||
policy
|
||||
);
|
||||
if ('message' in validatedUrl) {
|
||||
return validatedUrl;
|
||||
}
|
||||
|
||||
url.href = normalizedUrl.href;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handlePlaylistParse(options: {
|
||||
readonly guid: () => string;
|
||||
readonly httpClient: WebBackendHttpClient;
|
||||
@@ -667,8 +515,6 @@ async function handlePlaylistParse(options: {
|
||||
readonly userAgent?: string;
|
||||
}): Promise<Record<string, unknown> | PlaylistParseError> {
|
||||
try {
|
||||
// Provider URLs are validated by /provider-targets before playlist parsing.
|
||||
// codeql[js/request-forgery]
|
||||
const response = await options.httpClient.get<string>(options.url, {
|
||||
timeout: PROVIDER_REQUEST_TIMEOUT_MS.playlist,
|
||||
...(options.userAgent
|
||||
@@ -689,14 +535,19 @@ async function handlePlaylistParse(options: {
|
||||
};
|
||||
} catch (error) {
|
||||
logProviderRequestFailure({ error, route: '/parse', url: options.url });
|
||||
const providerError = error as ProviderError;
|
||||
if (error instanceof ProviderRequestError && error.policyError) {
|
||||
return providerUrlErrorBody(error.policyError);
|
||||
}
|
||||
const providerError = (
|
||||
error instanceof ProviderRequestError ? error.cause : error
|
||||
) as ProviderError;
|
||||
if (providerError?.response?.statusText !== undefined) {
|
||||
return {
|
||||
status: providerError.response.status ?? 500,
|
||||
message: providerError.response.statusText,
|
||||
};
|
||||
}
|
||||
const code = collectProviderErrorCodes(error)[0];
|
||||
const code = collectProviderErrorCodes(providerError)[0];
|
||||
return {
|
||||
status: providerError?.response?.status ?? 500,
|
||||
message: code
|
||||
@@ -712,8 +563,6 @@ async function fetchEpgDataFromUrl(
|
||||
url: URL
|
||||
): Promise<unknown> {
|
||||
const href = url.href;
|
||||
// Provider URLs are validated by /provider-targets before XMLTV parsing.
|
||||
// codeql[js/request-forgery]
|
||||
const response = await httpClient.get<ArrayBuffer | string>(href, {
|
||||
timeout: PROVIDER_REQUEST_TIMEOUT_MS.epg,
|
||||
...(url.pathname.endsWith('.gz')
|
||||
@@ -791,67 +640,3 @@ function getLastUrlSegment(value: string): string {
|
||||
function createGuid(): string {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
function isLocalHostname(hostname: string): boolean {
|
||||
return hostname === 'localhost' || hostname.endsWith('.localhost');
|
||||
}
|
||||
|
||||
function isPrivateOrReservedIp(address: string): boolean {
|
||||
const version = isIP(address);
|
||||
if (version === 4) {
|
||||
return isPrivateOrReservedIpv4(address);
|
||||
}
|
||||
|
||||
if (version === 6) {
|
||||
return isPrivateOrReservedIpv6(address);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateOrReservedIpv4(address: string): boolean {
|
||||
const parts = address.split('.').map((part) => Number(part));
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [first, second, third] = parts;
|
||||
return (
|
||||
first === 0 ||
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 100 && second >= 64 && second <= 127) ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168) ||
|
||||
(first === 192 && second === 0) ||
|
||||
(first === 192 && second === 0 && third === 2) ||
|
||||
(first === 198 && (second === 18 || second === 19)) ||
|
||||
(first === 198 && second === 51 && third === 100) ||
|
||||
(first === 203 && second === 0 && third === 113) ||
|
||||
first >= 224
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateOrReservedIpv6(address: string): boolean {
|
||||
const normalized = address.toLowerCase();
|
||||
if (
|
||||
normalized === '::' ||
|
||||
normalized === '::1' ||
|
||||
normalized.startsWith('fc') ||
|
||||
normalized.startsWith('fd') ||
|
||||
normalized.startsWith('fe80:')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const mappedIpv4 = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
||||
return mappedIpv4 ? isPrivateOrReservedIpv4(mappedIpv4) : false;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"sourceRoot": "apps/web-e2e/src",
|
||||
"implicitDependencies": [
|
||||
"web",
|
||||
"web-backend",
|
||||
"stalker-mock-server",
|
||||
"xtream-mock-server"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
/** Category scope, independent fullscreen search and playback continuity (#1543). */
|
||||
export async function verifyStalkerCategorySearch(page: Page): Promise<void> {
|
||||
const sidebarSearch = page.locator('input[type="search"]').first();
|
||||
const categories = page.locator('.category-item');
|
||||
const sidebarRows = page.locator(
|
||||
'#live-channels [data-test-id="channel-item"]'
|
||||
);
|
||||
await sidebarSearch.fill('TV');
|
||||
await sidebarSearch.press('Enter');
|
||||
await expect(page).toHaveURL(/q=TV/);
|
||||
await expect(sidebarRows).toHaveCount(40);
|
||||
const firstNames = await sidebarRows
|
||||
.locator('.channel-name')
|
||||
.allTextContents();
|
||||
// All Items already started playback earlier in this workflow. Retain that
|
||||
// settled selection; clicking again would introduce a pending replacement.
|
||||
const player = page.locator('app-web-player-view');
|
||||
await expect(player).toBeVisible();
|
||||
const video = player.locator('video').first();
|
||||
const videoNode = await video.elementHandle();
|
||||
const playerNode = await player.elementHandle();
|
||||
|
||||
await player.hover();
|
||||
await player.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await page
|
||||
.locator('[data-test-id="fullscreen-channel-panel-hot-zone"]')
|
||||
.hover();
|
||||
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
const panelSearch = panel.getByRole('searchbox');
|
||||
const panelRows = panel.locator('[data-test-id="channel-item"]');
|
||||
await panelSearch.fill('TV');
|
||||
await expect(panelRows).toHaveCount(40);
|
||||
await expect(panelRows.locator('.channel-name')).toHaveText(firstNames);
|
||||
await panelSearch.fill(firstNames[30].trim());
|
||||
await expect(panelRows).toHaveCount(1);
|
||||
await expect(sidebarSearch).toHaveValue('TV');
|
||||
await expect(sidebarRows).toHaveCount(40);
|
||||
await page.evaluate(() => document.exitFullscreen());
|
||||
|
||||
// Navigation retains the applied term and playing engine, including a channel
|
||||
// outside the new category. Its separate selection UX is tracked in #1520.
|
||||
await categories.nth(2).click();
|
||||
await expect(sidebarSearch).toHaveValue('TV');
|
||||
await expect(sidebarRows).toHaveCount(40);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const names = await sidebarRows
|
||||
.locator('.channel-name')
|
||||
.allTextContents();
|
||||
return names.every((name) => !firstNames.includes(name));
|
||||
})
|
||||
.toBe(true);
|
||||
expect(await playerNode?.evaluate((element) => element.isConnected)).toBe(
|
||||
true
|
||||
);
|
||||
expect(await videoNode?.evaluate((element) => element.isConnected)).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
await sidebarSearch.fill('');
|
||||
await sidebarSearch.press('Enter');
|
||||
await expect(page).not.toHaveURL(/q=/);
|
||||
await expect(sidebarRows).toHaveCount(40);
|
||||
// The category-to-channel keyboard hand-off still targets the sidebar.
|
||||
await categories.nth(2).focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('#live-channels')).toBeFocused();
|
||||
|
||||
await categories.first().click();
|
||||
await sidebarSearch.fill('TV');
|
||||
await sidebarSearch.press('Enter');
|
||||
await expect(page).toHaveURL(/q=TV/);
|
||||
// Explicit All Items is a window over all 320 public channels.
|
||||
await expect(page.locator('.category-subtitle').first()).toContainText(
|
||||
'320'
|
||||
);
|
||||
}
|
||||
|
||||
/** A category absent from get_all_channels must page for a late local match. */
|
||||
export async function verifyUncachedStalkerSearch(
|
||||
page: Page,
|
||||
mockServer: string
|
||||
): Promise<void> {
|
||||
const response = await page.request.get(`${mockServer}/stalker`, {
|
||||
params: {
|
||||
url: `${mockServer}/portal.php`,
|
||||
action: 'get_ordered_list',
|
||||
type: 'itv',
|
||||
genre: '1099',
|
||||
category: '1099',
|
||||
p: '3',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
},
|
||||
});
|
||||
const body = await response.json();
|
||||
const lateName = String(body.payload.js.data[0].name);
|
||||
const search = page.locator('input[type="search"]').first();
|
||||
const requests: URL[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.searchParams.get('genre') === '1099') requests.push(url);
|
||||
});
|
||||
await search.fill(lateName);
|
||||
await search.press('Enter');
|
||||
await expect(page.locator('#live-channels .channel-name')).toHaveText(
|
||||
[lateName],
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
expect(requests.some((url) => Number(url.searchParams.get('p')) >= 3)).toBe(
|
||||
true
|
||||
);
|
||||
expect(requests.every((url) => !url.searchParams.has('search'))).toBe(true);
|
||||
await search.fill('TV');
|
||||
await search.press('Enter');
|
||||
await expect(page).toHaveURL(/q=TV/);
|
||||
await expect(
|
||||
page.locator('#live-channels [data-test-id="channel-item"]')
|
||||
).toHaveCount(40);
|
||||
await verifyStalkerPanelCategory(page, search);
|
||||
}
|
||||
|
||||
async function verifyStalkerPanelCategory(
|
||||
page: Page,
|
||||
search: ReturnType<Page['locator']>
|
||||
): Promise<void> {
|
||||
const rows = page.locator('#live-channels [data-test-id="channel-item"]');
|
||||
const names = await rows.locator('.channel-name').allTextContents();
|
||||
await rows.first().click();
|
||||
await search.fill(names[0].trim());
|
||||
await search.press('Enter');
|
||||
await expect(rows).toHaveCount(1);
|
||||
const player = page.locator('app-web-player-view');
|
||||
await player.hover();
|
||||
await player.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await page
|
||||
.locator('[data-test-id="fullscreen-channel-panel-hot-zone"]')
|
||||
.hover();
|
||||
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(panel.locator('.channel-name')).toHaveText(names);
|
||||
await panel.getByRole('searchbox').fill(names[30].trim());
|
||||
await expect(panel.locator('.channel-name')).toHaveText([names[30]]);
|
||||
await expect(search).toHaveValue(names[0].trim());
|
||||
await page.evaluate(() => document.exitFullscreen());
|
||||
}
|
||||
|
||||
/** A category change and return retain the exact live media element (#1520). */
|
||||
export async function verifyStalkerPlaybackCategoryReturn(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const sidebar = page.locator('app-stalker-live-stream-layout .sidebar');
|
||||
const sidebarTitle = sidebar.locator('.category-title');
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
const scrollPane = sidebar.locator('#live-channels');
|
||||
await categories.nth(1).focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(scrollPane).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(categories.nth(1)).toBeFocused();
|
||||
const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? '';
|
||||
expect(firstCategoryTitle).not.toBe('');
|
||||
|
||||
await channels.first().click();
|
||||
await expect(scrollPane).toBeFocused();
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => scrollPane.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||||
const player = page.locator('app-web-player-view');
|
||||
await expect(player).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const media = await player.locator('video').first().elementHandle();
|
||||
expect(media).not.toBeNull();
|
||||
const activeName = await channels
|
||||
.first()
|
||||
.locator('.channel-name')
|
||||
.textContent();
|
||||
await categories.nth(2).click();
|
||||
|
||||
// The sidebar re-filters to the new category (proves the click landed and
|
||||
// change detection ran)…
|
||||
await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
// …while the channel picked from the previous category keeps playing.
|
||||
await expect(player).toBeVisible();
|
||||
const reveal = page.getByRole('button', {
|
||||
name: 'Show playing channel',
|
||||
exact: true,
|
||||
});
|
||||
await reveal.click();
|
||||
await expect(sidebarTitle).toHaveText(firstCategoryTitle);
|
||||
await expect(scrollPane).toBeFocused();
|
||||
await expect(sidebar.locator('.active')).toContainText(activeName ?? '');
|
||||
expect(
|
||||
await media?.evaluate(
|
||||
(video) =>
|
||||
video === document.querySelector('app-web-player-view video')
|
||||
)
|
||||
).toBe(true);
|
||||
await expect(reveal).toHaveCount(0);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
verifyStalkerCategorySearch,
|
||||
verifyStalkerPlaybackCategoryReturn,
|
||||
verifyUncachedStalkerSearch,
|
||||
} from './stalker-category-search.fixture';
|
||||
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
@@ -463,53 +468,9 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('@stalker ITV playback survives a category switch', async ({ page }) => {
|
||||
// Regression: the shell context panel used to clear the selected Stalker
|
||||
// item on every category click, tearing down the player for a channel the
|
||||
// user never switched away from. Xtream live (#936) and M3U groups keep
|
||||
// playing across a category/group switch; Stalker must too.
|
||||
await addStalkerPortal(page);
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const sidebar = page.locator('app-stalker-live-stream-layout .sidebar');
|
||||
const sidebarTitle = sidebar.locator('.category-title');
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
const scrollPane = sidebar.locator('#live-channels');
|
||||
await categories.nth(1).focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(scrollPane).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(categories.nth(1)).toBeFocused();
|
||||
const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? '';
|
||||
expect(firstCategoryTitle).not.toBe('');
|
||||
|
||||
await channels.first().click();
|
||||
await expect(scrollPane).toBeFocused();
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => scrollPane.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||||
const player = page.locator('app-web-player-view');
|
||||
await expect(player).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await categories.nth(2).click();
|
||||
|
||||
// The sidebar re-filters to the new category (proves the click landed and
|
||||
// change detection ran)…
|
||||
await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
// …while the channel picked from the previous category keeps playing.
|
||||
await expect(player).toBeVisible();
|
||||
await verifyStalkerPlaybackCategoryReturn(page);
|
||||
await expect(page.locator('app-web-player-view')).toBeVisible();
|
||||
});
|
||||
|
||||
test('@stalker radio — stations use the inline audio player without EPG', async ({
|
||||
@@ -712,7 +673,9 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover
|
||||
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
const channels = page.locator(
|
||||
'#live-channels [data-test-id="channel-item"]'
|
||||
);
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Regression for "search only finds the first 14 loaded items": once the
|
||||
@@ -722,6 +685,9 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||||
const firstCategoryNames = await channels
|
||||
.locator('.channel-name')
|
||||
.allTextContents();
|
||||
|
||||
// Regression: switching to another category once the full list is cached
|
||||
// must serve that category from the cache, not get stuck on an empty
|
||||
@@ -744,6 +710,12 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// Counts are identical across categories; wait for the actual category
|
||||
// rows before choosing a search term, or it may come from the previous one.
|
||||
await expect(channels.locator('.channel-name')).toHaveText(
|
||||
firstCategoryNames
|
||||
);
|
||||
|
||||
// Search a channel from deep in the list (beyond the first 14 items).
|
||||
const deepChannelName = (
|
||||
await channels.nth(30).locator('.channel-name').textContent()
|
||||
@@ -762,6 +734,7 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
// The "loaded only" degraded-search hint must be gone in full-list mode.
|
||||
await expect(page.locator('.search-chip--status')).toHaveCount(0);
|
||||
await verifyStalkerCategorySearch(page);
|
||||
});
|
||||
|
||||
test('@stalker ITV censored category pages from the portal and hides its badge', async ({
|
||||
@@ -803,6 +776,7 @@ test('@stalker ITV censored category pages from the portal and hides its badge',
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect.poll(() => adultListRequests.length).toBeGreaterThan(0);
|
||||
await verifyUncachedStalkerSearch(page, MOCK_SERVER);
|
||||
});
|
||||
|
||||
test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "أعدها باستخدام الزر أدناه أو اضغط ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "إظهار القناة قيد التشغيل",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى",
|
||||
"ALL_CHANNELS": "جميع القنوات",
|
||||
"GROUPS": "المجموعات",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "نظرة عامة على اليوم",
|
||||
"ZOOM_HOURS": "حسب الساعة",
|
||||
"ZOOM_DETAIL": "تفصيلي",
|
||||
"ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير للضبط الدقيق",
|
||||
"GROUP_SHORT": "برامج قصيرة",
|
||||
"GROUP_EXPAND": "عرض ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "رجّعها بالزر اللي تحت ولا ضغط على ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "بيّن القناة اللي خدامة",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى",
|
||||
"ALL_CHANNELS": "جميع القنوات",
|
||||
"GROUPS": "المجموعات",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "نظرة عامة على النهار",
|
||||
"ZOOM_HOURS": "بالساعة",
|
||||
"ZOOM_DETAIL": "مفصّل",
|
||||
"ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير باش تضبط",
|
||||
"GROUP_SHORT": "برامج قصيرة",
|
||||
"GROUP_EXPAND": "وري ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Вярніце яго кнопкай ніжэй або націсніце ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Паказаць бягучы канал",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст",
|
||||
"ALL_CHANNELS": "Усе каналы",
|
||||
"GROUPS": "Групы",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Агляд дня",
|
||||
"ZOOM_HOURS": "Па гадзінах",
|
||||
"ZOOM_DETAIL": "Падрабязна",
|
||||
"ZOOM_TOOLTIP": "Маштаб: {{level}} · ⌘/Ctrl + кола для дакладнай наладкі",
|
||||
"GROUP_SHORT": "кароткія перадачы",
|
||||
"GROUP_EXPAND": "паказаць ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Blenden Sie sie über die Schaltfläche unten oder mit ⌘/Strg+B wieder ein."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Laufenden Sender anzeigen",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
|
||||
"ALL_CHANNELS": "Alle Sender",
|
||||
"GROUPS": "Gruppen",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Tagesübersicht",
|
||||
"ZOOM_HOURS": "Stundenweise",
|
||||
"ZOOM_DETAIL": "Detailliert",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Strg + Scrollen zum Feinabstimmen",
|
||||
"GROUP_SHORT": "kurze Sendungen",
|
||||
"GROUP_EXPAND": "anzeigen ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Επαναφέρετέ την με το παρακάτω κουμπί ή πατήστε ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Εμφάνιση του καναλιού που παίζει",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
|
||||
"ALL_CHANNELS": "Όλα τα κανάλια",
|
||||
"GROUPS": "Ομάδες",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Επισκόπηση ημέρας",
|
||||
"ZOOM_HOURS": "Ανά ώρα",
|
||||
"ZOOM_DETAIL": "Λεπτομερής",
|
||||
"ZOOM_TOOLTIP": "Ζουμ: {{level}} · ⌘/Ctrl + κύλιση για λεπτομερή ρύθμιση",
|
||||
"GROUP_SHORT": "σύντ. προγρ.",
|
||||
"GROUP_EXPAND": "εμφάνιση ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Bring it back with the button below or press ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Show playing channel",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist",
|
||||
"ALL_CHANNELS": "All channels",
|
||||
"GROUPS": "Groups",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Day overview",
|
||||
"ZOOM_HOURS": "By hour",
|
||||
"ZOOM_DETAIL": "Detailed",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + scroll to fine-tune",
|
||||
"GROUP_SHORT": "short progs",
|
||||
"GROUP_EXPAND": "show ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Vuelve a mostrarla con el botón de abajo o pulsa ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostrar el canal en reproducción",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción",
|
||||
"ALL_CHANNELS": "Todos los canales",
|
||||
"GROUPS": "Grupos",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Vista del día",
|
||||
"ZOOM_HOURS": "Por hora",
|
||||
"ZOOM_DETAIL": "Detallado",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + rueda para ajustar",
|
||||
"GROUP_SHORT": "progs. cortos",
|
||||
"GROUP_EXPAND": "ver ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Réaffichez-la avec le bouton ci-dessous ou appuyez sur ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Afficher la chaîne en cours",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture",
|
||||
"ALL_CHANNELS": "Toutes les chaînes",
|
||||
"GROUPS": "Groupes",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Vue de la journée",
|
||||
"ZOOM_HOURS": "Par heure",
|
||||
"ZOOM_DETAIL": "Détaillé",
|
||||
"ZOOM_TOOLTIP": "Zoom : {{level}} · ⌘/Ctrl + molette pour ajuster",
|
||||
"GROUP_SHORT": "progs courts",
|
||||
"GROUP_EXPAND": "afficher ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Az alábbi gombbal vagy a ⌘/Ctrl+B billentyűkkel jelenítheti meg újra."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Lejátszott csatorna megjelenítése",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Másik lejátszási lista feltöltése vagy kiválasztása",
|
||||
"ALL_CHANNELS": "Összes csatorna",
|
||||
"GROUPS": "Csoportok",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Napi áttekintés",
|
||||
"ZOOM_HOURS": "Óránként",
|
||||
"ZOOM_DETAIL": "Részletes",
|
||||
"ZOOM_TOOLTIP": "Nagyítás: {{level}} · ⌘/Ctrl + görgetés a finomhangoláshoz",
|
||||
"GROUP_SHORT": "rövid műsorok",
|
||||
"GROUP_EXPAND": "megjelenítés ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Mostralo di nuovo con il pulsante qui sotto o premi ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostra il canale in riproduzione",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist",
|
||||
"ALL_CHANNELS": "Tutti i canali",
|
||||
"GROUPS": "Gruppi",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Panoramica giorno",
|
||||
"ZOOM_HOURS": "Per ora",
|
||||
"ZOOM_DETAIL": "Dettagliato",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + rotella per regolare",
|
||||
"GROUP_SHORT": "prog. brevi",
|
||||
"GROUP_EXPAND": "mostra ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "下のボタンまたは ⌘/Ctrl+B で再表示できます。"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "再生中のチャンネルを表示",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択",
|
||||
"ALL_CHANNELS": "すべてのチャンネル",
|
||||
"GROUPS": "グループ",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "1日表示",
|
||||
"ZOOM_HOURS": "時間単位",
|
||||
"ZOOM_DETAIL": "詳細",
|
||||
"ZOOM_TOOLTIP": "ズーム: {{level}} · ⌘/Ctrl + スクロールで微調整",
|
||||
"GROUP_SHORT": "短い番組",
|
||||
"GROUP_EXPAND": "表示 ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "아래 버튼을 누르거나 ⌘/Ctrl+B를 눌러 다시 표시하세요."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "재생 중인 채널 표시",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오",
|
||||
"ALL_CHANNELS": "모든 채널",
|
||||
"GROUPS": "그룹",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "하루 보기",
|
||||
"ZOOM_HOURS": "시간별",
|
||||
"ZOOM_DETAIL": "상세",
|
||||
"ZOOM_TOOLTIP": "확대/축소: {{level}} · ⌘/Ctrl + 스크롤로 미세 조정",
|
||||
"GROUP_SHORT": "짧은 프로그램",
|
||||
"GROUP_EXPAND": "보기 ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Toon de lijst opnieuw met de knop hieronder of druk op ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Huidige zender tonen",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst",
|
||||
"ALL_CHANNELS": "Alle kanalen",
|
||||
"GROUPS": "Groepen",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Dagoverzicht",
|
||||
"ZOOM_HOURS": "Per uur",
|
||||
"ZOOM_DETAIL": "Gedetailleerd",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + scrollen om fijn af te stellen",
|
||||
"GROUP_SHORT": "korte progr.",
|
||||
"GROUP_EXPAND": "toon ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Przywróć ją przyciskiem poniżej lub naciśnij ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Pokaż odtwarzany kanał",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania",
|
||||
"ALL_CHANNELS": "Wszystkie kanały",
|
||||
"GROUPS": "Grupy",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Przegląd dnia",
|
||||
"ZOOM_HOURS": "Co godzinę",
|
||||
"ZOOM_DETAIL": "Szczegółowo",
|
||||
"ZOOM_TOOLTIP": "Powiększenie: {{level}} · ⌘/Ctrl + kółko, aby dostroić",
|
||||
"GROUP_SHORT": "krótkie progr.",
|
||||
"GROUP_EXPAND": "pokaż ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Volte a mostrá-la com o botão abaixo ou pressione ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostrar canal em reprodução",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist",
|
||||
"ALL_CHANNELS": "Todos os canais",
|
||||
"GROUPS": "Grupos",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Visão geral do dia",
|
||||
"ZOOM_HOURS": "Por hora",
|
||||
"ZOOM_DETAIL": "Detalhado",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + roda para ajustar",
|
||||
"GROUP_SHORT": "progs. curtos",
|
||||
"GROUP_EXPAND": "mostrar ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Верните его кнопкой ниже или нажмите ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Показать текущий канал",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист",
|
||||
"ALL_CHANNELS": "Все каналы",
|
||||
"GROUPS": "Группы",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Обзор дня",
|
||||
"ZOOM_HOURS": "По часам",
|
||||
"ZOOM_DETAIL": "Детально",
|
||||
"ZOOM_TOOLTIP": "Масштаб: {{level}} · ⌘/Ctrl + колесо для точной настройки",
|
||||
"GROUP_SHORT": "короткие передачи",
|
||||
"GROUP_EXPAND": "показать ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Aşağıdaki düğmeyle geri getirin veya ⌘/Ctrl+B tuşlarına basın."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Oynatılan kanalı göster",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin",
|
||||
"ALL_CHANNELS": "Tüm kanallar",
|
||||
"GROUPS": "Gruplar",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "Gün özeti",
|
||||
"ZOOM_HOURS": "Saatlik",
|
||||
"ZOOM_DETAIL": "Detaylı",
|
||||
"ZOOM_TOOLTIP": "Yakınlaştırma: {{level}} · İnce ayar için ⌘/Ctrl + kaydırma",
|
||||
"GROUP_SHORT": "kısa progr.",
|
||||
"GROUP_EXPAND": "göster ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "点击下方按钮或按 ⌘/Ctrl+B 重新显示。"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "显示正在播放的频道",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表",
|
||||
"ALL_CHANNELS": "所有频道",
|
||||
"GROUPS": "分组",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "全天概览",
|
||||
"ZOOM_HOURS": "按小时",
|
||||
"ZOOM_DETAIL": "详细",
|
||||
"ZOOM_TOOLTIP": "缩放:{{level}} · ⌘/Ctrl + 滚轮微调",
|
||||
"GROUP_SHORT": "短节目",
|
||||
"GROUP_EXPAND": "展开 ›"
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "點選下方按鈕或按 ⌘/Ctrl+B 重新顯示。"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "顯示正在播放的頻道",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單",
|
||||
"ALL_CHANNELS": "所有頻道",
|
||||
"GROUPS": "群組",
|
||||
@@ -838,6 +839,7 @@
|
||||
"ZOOM_DAY": "日總覽",
|
||||
"ZOOM_HOURS": "依小時",
|
||||
"ZOOM_DETAIL": "詳細",
|
||||
"ZOOM_TOOLTIP": "縮放:{{level}} · ⌘/Ctrl + 滾輪微調",
|
||||
"GROUP_SHORT": "短節目",
|
||||
"GROUP_EXPAND": "顯示 ›"
|
||||
}
|
||||
|
||||
@@ -47,11 +47,15 @@ so a silent host hung on OS-level TCP timeouts. Both runtimes now use the same
|
||||
budgets: Xtream 30 s, Stalker 15 s (30 s for `create_link`, which mints a stream
|
||||
URL before answering), playlist and XMLTV downloads 30 s.
|
||||
|
||||
Those numbers are safe for large downloads. On axios' default
|
||||
(follow-redirects) transport `timeout` is **not** a wall-clock deadline for the
|
||||
Those numbers are safe for large downloads. On axios 1.20's native HTTP
|
||||
transport (`maxRedirects: 0`) each hop's `timeout` is **not** a wall-clock deadline for the
|
||||
whole response: it bounds the time to response headers and then continues as the
|
||||
socket's inactivity timeout for the body. A multi-megabyte XMLTV file that keeps
|
||||
delivering bytes is never cut off mid-transfer — only a stalled one is.
|
||||
delivering bytes is never cut off mid-transfer — only a stalled one is. The web
|
||||
backend explicitly restores socket inactivity handling while reading the final
|
||||
stream response, because axios stream mode stops its timeout handler at headers.
|
||||
Truncated, malformed and timed-out final bodies retain their received response
|
||||
status for reachability classification; cancellation retains its own semantics.
|
||||
|
||||
### Scope: portal calls only
|
||||
|
||||
@@ -124,33 +128,27 @@ Merely declining to count it would leave an earlier direct failure standing, and
|
||||
a single later timeout would then fast-fail an endpoint that answered in between.
|
||||
Every caller passes the URL it asked for as the baseline.
|
||||
|
||||
**Where the failed hop is found depends on the transport, and both are in play.**
|
||||
**Web backend records redirect evidence explicitly.** Its `ValidatedHttpClient`
|
||||
disables automatic redirects and holds one admission for the entire chain.
|
||||
`ProviderRequestError.initialResponded` becomes true only after receiving an
|
||||
actual redirect response. A later transport or DNS failure, URL policy refusal,
|
||||
invalid location, cycle or exhausted budget therefore clears the initial
|
||||
endpoint's failure streak when the chain settles. This also handles redirects
|
||||
that change only the query. It does not release the half-open slot early: the
|
||||
route retains ownership through validation, all hops and the final body, and
|
||||
releases in `finally` even when reporting throws. Before any redirect, initial
|
||||
DNS failures carry their internal resolver cause into normal classification;
|
||||
policy refusals remain inconclusive. Error bodies never serialize that cause.
|
||||
|
||||
| | Electron | Web backend |
|
||||
| --- | --- | --- |
|
||||
| Redirects | followed hop by hop (`maxRedirects: 0`), each its own request | followed inside one request by follow-redirects |
|
||||
| Failed hop is in | `error.config.url` | `error.request._currentUrl` |
|
||||
|
||||
`failedRequestUrlOf` reads `_currentUrl` first and falls back to `config.url`,
|
||||
which is correct for both: a per-hop request exposes no `_currentUrl`, and on the
|
||||
following transport `config` is built once and keeps the URL we asked for — so
|
||||
reading `config.url` there would compare a URL with itself, find no redirect, and
|
||||
charge a dead destination to the provider that answered. Anything added here must
|
||||
work on both, because the same helper serves both.
|
||||
|
||||
**The comparison is origin + path, not the whole URL.** A same-origin redirect
|
||||
(`/player_api.php` → `/slow/player_api.php`) proves the endpoint answered just as
|
||||
much as a cross-origin one, and charging it would fast-fail every OTHER call to a
|
||||
portal that answers — so the path has to be part of it. The query must NOT be:
|
||||
the web backend passes Xtream credentials through axios' `params`, so the sent
|
||||
URL always carries a query the baseline does not, and comparing whole URLs made
|
||||
every ordinary failure look like a redirect and stopped the breaker from ever
|
||||
opening. What that gives up is a redirect that changes nothing but the query,
|
||||
which is then counted as an ordinary failure — the safe direction.
|
||||
|
||||
It requires positive evidence — anything unparseable or unknown counts the
|
||||
failure as usual, because guessing "redirect" here would stop the guard from ever
|
||||
tripping — and a failure that names no URL at all is still counted.
|
||||
**Electron retains URL-based attribution.** `failedRequestUrlOf` reads
|
||||
`error.request._currentUrl` first (for a following transport) and falls back to
|
||||
`error.config.url` (for a native per-hop transport). The comparison is origin +
|
||||
path, not the query: axios `params` can append credentials absent from the
|
||||
caller's baseline. Unknown/unparseable URLs conservatively count as ordinary
|
||||
failures, and query-only redirects cannot be distinguished by this fallback.
|
||||
Wrapped web-backend requests use their explicit chain evidence and never infer
|
||||
redirects from the transport's fixed logical hostname. The shared helper and
|
||||
Electron behavior are unchanged by the web-backend fix.
|
||||
|
||||
Known gap: the failing hop is not guarded either (it has no token of its own),
|
||||
so a permanently broken redirect chain keeps costing a full timeout.
|
||||
|
||||
@@ -836,12 +836,22 @@ activation, and the details dialog behave identically to the timeline.
|
||||
(**vertical title**, no time) → `micro` (just a marker); (C) a **hover/focus
|
||||
popover** revealing the full title + time + description for any non-`wide`
|
||||
block (it flips above the block when the panel is near the screen bottom);
|
||||
(D) a px-per-minute **zoom** slider (tick density adapts via
|
||||
`timelineTickStepForScale`); and (E) **grouping** of ≥4 consecutive short
|
||||
(D) a px-per-minute **zoom** (tick density adapts via
|
||||
`timelineTickStepForScale`): the toolbar's icon-only zoom button cycles
|
||||
three presets — day overview (`1`) → by hour (`1.75`, the default) →
|
||||
detailed (`3.4`, the scale a group chip expands to) — snapping a
|
||||
wheel-tuned scale to its band's successor (`TIMELINE_ZOOM_LEVELS`,
|
||||
`nextTimelineZoomScale`), while Ctrl/⌘ + wheel (and trackpad pinch) over
|
||||
the ribbon zooms continuously around the cursor within
|
||||
`TIMELINE_ZOOM_MIN..MAX` and `preventDefault`s so Chromium never page-zooms
|
||||
the same gesture; the current level lives in the tooltip/`aria-label` and
|
||||
a `data-zoom-level` attribute; and (E) **grouping** of ≥4 consecutive short
|
||||
(<10 min) programmes into one dashed "N short" chip when zoomed out
|
||||
(`scale < TIMELINE_GROUP_ZOOM_MAX`), expanded by clicking it. The ribbon
|
||||
canvas lives in the child `app-epg-timeline-track`; the parent owns the
|
||||
scroller, toolbar (incl. the zoom slider) and state.
|
||||
scroller, toolbar (icon-only "Now" + zoom buttons, both labelled through
|
||||
tooltip + `aria-label`, so the channel/programme heading takes every pixel
|
||||
the fixed-width controls leave) and state.
|
||||
- **Panel height & titles.** Block titles wrap onto as many lines as the card
|
||||
height allows and are clipped (not single-line ellipsis); the foot ("ON NOW"
|
||||
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
|
||||
@@ -1452,7 +1462,6 @@ resolve another retained source sharing that channel ID. Legacy programmes with
|
||||
unknown (`NULL`) ownership are conservatively left alone; the existing database
|
||||
initialization backfill handles rows whose channel still identifies their owner.
|
||||
|
||||
|
||||
Renderer reconciliation fences lookups before its first asynchronous step.
|
||||
Imports wait for serialized reconciliation (including playlist migration), then
|
||||
filter against its committed owner set. Completion increments the data revision again
|
||||
|
||||
@@ -27,6 +27,12 @@ Do not invent a `test`, `build`, or `e2e` target because a similarly named
|
||||
project has one. Run affected lint/test/build targets that exist and the closest
|
||||
available E2E target for the changed behavior.
|
||||
|
||||
E2E applications must declare runtime dependencies even when they use HTTP
|
||||
instead of TypeScript imports. `web-e2e` includes `web-backend` in
|
||||
`implicitDependencies` so provider-proxy changes invalidate cached self-hosted
|
||||
PWA tests; starting the backend through a `serve` dependency alone does not
|
||||
make its source files inputs to the test hash.
|
||||
|
||||
## Nx Dependency Updates
|
||||
|
||||
Keep `nx` and every official `@nx/*` package on the same exact version. Run
|
||||
|
||||
@@ -378,11 +378,11 @@ panel copy's scroll — because in full-list mode a broad term matches most of
|
||||
a multi-thousand-channel portal and the list has no virtual scroll; on a paged
|
||||
portal the panel copy also keeps requesting pages while its matches do not
|
||||
fill it — an empty or short result cannot scroll, and the term may match
|
||||
channels on pages never fetched; the sidebar's own search term does not stop
|
||||
it, since that term only gates the sidebar copy's automatic fill, and a page
|
||||
landing resets the in-flight flag whether or not the sidebar shows any of it
|
||||
— while in full-list mode it never pages, since its search already sees the
|
||||
whole catalog; closing the panel pauses window growth and automatic page
|
||||
channels on pages never fetched. Both ITV fields search the selected category,
|
||||
and only All Items searches the portal. The store's ITV pages are unfiltered
|
||||
by either field; a short sidebar search also continues uncached category pages.
|
||||
A page landing resets the in-flight flag whether or not the sidebar shows any
|
||||
of it; a cached category never pages, since its entire category is searchable; closing the panel pauses window growth and automatic page
|
||||
requests while preserving the mounted list, and an observer of the aside's
|
||||
`inert` attribute resumes filling on reopen and disconnects with the list;
|
||||
inline video keeps the selected channel paired with its retained playback
|
||||
|
||||
@@ -139,6 +139,63 @@ before any outbound request:
|
||||
- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` explicitly enables trusted
|
||||
local/LAN targets for development, mock servers, or private deployments
|
||||
|
||||
### Provider redirects and connection policy
|
||||
|
||||
`ValidatedHttpClient` applies the registration URL policy again before every
|
||||
outbound request on `/xtream`, `/stalker`, `/parse`, and `/parse-xml`, including
|
||||
the initial request. Axios automatic redirects are disabled (`maxRedirects: 0`);
|
||||
301, 302, 303, 307 and 308 are followed manually, with at most five redirects.
|
||||
Relative `Location` values resolve against the URL actually sent, including its
|
||||
query. Missing/malformed locations, repeated URLs (ignoring fragments), and an
|
||||
exhausted redirect budget fail without dispatching another request. Intermediate
|
||||
bodies are destroyed at headers; malformed or endless redirect bodies do not
|
||||
block following an otherwise valid location. Final bodies retain a native socket
|
||||
inactivity timer: axios stream mode stops its own timeout handling at headers,
|
||||
so `provider-response.ts` keeps that timer active until consumption finishes.
|
||||
A body failure preserves received HTTP status evidence; cancellation remains
|
||||
inconclusive before any redirect.
|
||||
|
||||
Every DNS answer must be a valid permitted IP. Mixed public/private answers
|
||||
fail closed. The strict policy rejects private/reserved IPv4, IPv4-mapped private
|
||||
IPv6, and IPv6 outside global unicast `2000::/3`, plus protocol-assignment,
|
||||
documentation and 6to4 ranges within it. These deliberately conservative ranges
|
||||
follow the [IANA IPv6 address space](https://www.iana.org/assignments/ipv6-address-space/)
|
||||
and [special-purpose registry](https://www.iana.org/assignments/iana-ipv6-special-registry/).
|
||||
The LAN opt-in applies to the whole
|
||||
chain; it still requires HTTP(S), no URL userinfo and concrete DNS addresses.
|
||||
|
||||
Fresh HTTP/HTTPS agents pin socket lookup to the exact validated IPv4/IPv6
|
||||
answer set for that hop. No second DNS query or pooled connection may substitute
|
||||
an unvalidated address. The default axios transport uses a fixed logical hostname (`provider.invalid`)
|
||||
so its connection authority cannot come from user-controlled URL metadata;
|
||||
the pinned lookup alone selects an IP. Its native request-options adapter sets
|
||||
`path` separately, so even `//host/path` cannot replace the connection authority.
|
||||
It also owns the deadline from dispatch through response headers, since axios's
|
||||
built-in connection timer only covers its own native transport objects. The original provider host and port are
|
||||
explicitly preserved in Host, TLS SNI and certificate identity checks (literal
|
||||
IPs omit SNI but still verify the original IP). Axios proxies and Node environment proxies
|
||||
are disabled for these requests so a proxy cannot independently resolve the
|
||||
origin. Deployments that require an outbound HTTP proxy must use a different
|
||||
network arrangement; setting `HTTP_PROXY`/`HTTPS_PROXY` does not route provider
|
||||
requests through it. This guarantee concerns address selection in the Node
|
||||
transport, not routing/NAT performed outside the process. Private-network
|
||||
opt-in intentionally relaxes address restrictions.
|
||||
|
||||
Original axios `params` are applied only to the initial request. Subsequent
|
||||
queries come from `Location` resolution, without appending the original
|
||||
credentials again. Authorization, Cookie, Proxy-Authorization and Stalker SN
|
||||
headers are retained only on the same origin; changes of host, port or scheme
|
||||
(including HTTPS downgrades) strip them for the rest of the chain. User-Agent
|
||||
and other non-secret protocol headers survive. Providers can explicitly put
|
||||
query values in `Location`; the backend does not rewrite provider-issued URLs.
|
||||
|
||||
Policy errors contain fixed messages/statuses without URLs, DNS exceptions or
|
||||
transport objects. Portal routes keep HTTP 200 with a `{ message, status }`
|
||||
error envelope; playlist/XMLTV routes use the actual error status. Registration
|
||||
continues to use real HTTP error statuses. See
|
||||
[host connectivity guard](host-connectivity-guard.md) for chain ownership and
|
||||
failure attribution.
|
||||
|
||||
Do not disable TLS certificate validation in the backend proxy. For private
|
||||
certificate authorities, configure Node with `NODE_EXTRA_CA_CERTS`.
|
||||
|
||||
|
||||
@@ -126,6 +126,36 @@ publishes it when the active channel is cleared IN PLACE (e.g. quitting an
|
||||
external MPV/VLC session dispatches `resetActiveChannel` while the route
|
||||
stays mounted).
|
||||
|
||||
## Live channel return and playback order
|
||||
|
||||
Xtream and Stalker live views keep a component-owned playback queue through
|
||||
`LiveChannelPlaybackQueue` in `portal-shared-data-access`. Explicit selection
|
||||
captures the actual displayed order, including search/sort and a fullscreen
|
||||
panel's own filter. Remote up/down, numeric selection and the published channel
|
||||
number use that queue while category or search browsing remains independent.
|
||||
Xtream history handoffs from global search or Recently Added capture the
|
||||
eligible destination category in the selected channel sort order; an unrelated
|
||||
previous category/query does not define that queue. Explicit All Items clicks
|
||||
still capture their displayed list. Same-channel replay and remote selection preserve it. Source/type changes and
|
||||
view destruction discard it; ITV and radio never share an owner.
|
||||
|
||||
Stalker captures before asynchronous URL resolution and commits only the winning
|
||||
successful request. Paged lists extend the queue only as more rows arrive for
|
||||
the original category and search scope. They do not fetch a global catalog for
|
||||
remote navigation. Xtream excludes removed streams and hidden or removed
|
||||
categories from eligible queue entries.
|
||||
|
||||
A conditional **Show playing channel** icon in the channel header appears when
|
||||
the playing channel is absent from the browsed results and its category remains
|
||||
accessible. It clears `q` and the store query, returns to that category, expands
|
||||
the sidebar, then scrolls and focuses the playing row. It never starts playback
|
||||
or changes the playback/session/catchup identity. A collapsed sidebar first uses
|
||||
its existing restore action. Removed categories are not recreated or unhidden.
|
||||
Stalker reuses the already-resolved playing item as a temporary normal row when
|
||||
it lies beyond loaded provider pages. This row is deduplicated once it arrives
|
||||
in provider results and discarded on browsing or playback changes; returning
|
||||
never crawls the catalog. Raw provider rows alone extend the playback queue.
|
||||
|
||||
## Shared helpers
|
||||
|
||||
- File: `libs/portal/shared/util/src/lib/remote-channel-navigation.ts`
|
||||
@@ -184,10 +214,10 @@ Implemented behavior:
|
||||
- `onRemoteControlCommand` for number select
|
||||
- Up/down:
|
||||
- Uses selected live item `selectedItem().xtream_id`
|
||||
- Navigates inside `selectItemsFromSelectedCategory()`
|
||||
- Navigates inside the captured eligible playback queue
|
||||
- Calls `playLive(nextItem, true)` so remote actions explicitly start playback
|
||||
- Number select:
|
||||
- Maps number to item in current category list
|
||||
- Maps number to item in the same captured eligible queue
|
||||
- Calls `playLive(channel, true)` so remote actions explicitly start playback
|
||||
- Publishes status via effect:
|
||||
- `portal: 'xtream'`
|
||||
@@ -207,10 +237,10 @@ Implemented behavior:
|
||||
- `onRemoteControlCommand` for number select
|
||||
- Up/down:
|
||||
- Uses `selectedItem().id`
|
||||
- Navigates inside `itvChannels()`
|
||||
- Navigates inside the captured ITV/radio playback queue
|
||||
- Calls `playChannel(nextItem, true)` so remote actions explicitly start playback
|
||||
- Number select:
|
||||
- Maps number into `itvChannels()`
|
||||
- Maps number into the same captured playback queue
|
||||
- Calls `playChannel(channel, true)` so remote actions explicitly start playback
|
||||
- Publishes status via effect:
|
||||
- `portal: 'stalker'`
|
||||
|
||||
@@ -1274,9 +1274,10 @@ The Stalker live route and radio route intentionally share
|
||||
in flight belong to the selection and survive the switch. Only a section
|
||||
change (`itv` ↔ `radio`, where the route session clears the selection)
|
||||
invalidates that request and drops the fallback. A playing channel outside
|
||||
the newly selected category simply has no highlighted row, and remote
|
||||
channel up/down finds no neighbour until a channel from the visible list is
|
||||
played.
|
||||
the newly selected category can be revealed with **Show playing channel**
|
||||
in the channel header. Remote up/down, numeric selection and status retain
|
||||
the captured playback order while browsing. See the
|
||||
[queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order).
|
||||
|
||||
## Full ITV Channel List Cache
|
||||
|
||||
@@ -1324,20 +1325,29 @@ list:
|
||||
- Loading state contract (important — regressions here strand the sidebar on a
|
||||
skeleton): in full-list mode the content loader serves the filtered list
|
||||
**synchronously** from the cache. The category-change reset effect therefore
|
||||
must NOT `setItvChannels([])` while `itvFullListActive()` is true — it runs
|
||||
must NOT `setItvChannels([])` while `itvSelectedCategoryFromCache()` is true — it runs
|
||||
after the store resource and would clobber the freshly served list, leaving
|
||||
every category after the first stuck on a skeleton. The initial-loading
|
||||
skeleton (`isInitialChannelsLoading`) must key off an actual in-flight load
|
||||
(`itvFullListLoading()` or `isPaginatedContentLoading()`), not merely an empty
|
||||
channel list; an empty result once loading has settled is an empty category
|
||||
and renders `PORTALS.NO_CHANNELS_IN_CATEGORY`, not a spinner.
|
||||
- Search: with the cache active, the header search spans the ENTIRE portal
|
||||
(all genres) — filtering the store's `itvFullChannelList`, not just the
|
||||
selected category — so searching "CNN" while a "Sports" genre is selected
|
||||
still finds it; clearing the term returns to the selected category. The
|
||||
workspace shell drops the `degraded-loaded-only` / "loaded only" status for
|
||||
Stalker ITV once `itvFullListActive`; radio (no full-list cache) always keeps
|
||||
the loaded-only hint (`workspace-shell-search.service.ts`).
|
||||
- Search (#1543): the sidebar and fullscreen fields independently filter the
|
||||
complete **selected category**, or the whole public catalog in All Items
|
||||
(both the uncategorized grid and `*`). Neither merges foreign cached genres
|
||||
into a category. ITV search never changes provider request parameters or
|
||||
resets accumulated pages: this keeps the fullscreen field independent of
|
||||
sidebar text. Cached categories are searched before the 100-row render
|
||||
window; uncached/censored categories continue `get_ordered_list` pages when
|
||||
a short/empty result cannot scroll, and continue on scroll otherwise. Search
|
||||
results use 100-row windows. Clearing or changing the query resets the render
|
||||
window, and category changes reset both list windows without restarting playback.
|
||||
Empty or repeated provider pages terminate pagination; a cache-ready replay
|
||||
of the same uncached page is deduplicated without hiding later pages. Aborted
|
||||
requests cannot overwrite the current category, even after navigating away
|
||||
and back. Radio stays on its separate station list and server-search flow.
|
||||
The workspace shell retains its loaded-only hint for ITV portals without
|
||||
a full cache and for radio.
|
||||
- Windowed selection: remote channel-up/down and numeric select operate over
|
||||
the full filtered category, so the render window (`renderLimit`) grows to
|
||||
include a selection beyond it (`ensureChannelWithinRenderWindow`) instead of
|
||||
|
||||
@@ -154,6 +154,11 @@ is watching keeps playing while the sidebar re-filters (Xtream: #936; Stalker:
|
||||
series category clicks do drop the open detail (`setSelectedItem(null)` /
|
||||
`clearSelectedItem()`) because they navigate to a list route.
|
||||
|
||||
The channel header offers **Show playing channel** when browsing excludes the
|
||||
active channel. It returns to that category and focuses the row without
|
||||
restarting playback; remote commands retain captured playback order. See the
|
||||
[queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order).
|
||||
|
||||
## Search And Navigation Rules
|
||||
|
||||
Search is shell-owned and route-aware:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Live channel return implementation plan
|
||||
|
||||
> **For agentic workers:** Use subagent-driven-development for the independently owned Xtream integration, with spec and code review. Execute the shared queue, Stalker integration and validation in this task. Do not merge the resulting PR.
|
||||
|
||||
**Goal:** Keep live remote navigation stable during category/search browsing and provide one conditional action to reveal the playing channel, without duplicating its title or restarting playback (#1520).
|
||||
|
||||
**Architecture:** Each live host owns a playback queue scoped by source and content type. Explicit channel activation captures the displayed ordered list; remote commands preserve it. Stalker extends the captured queue only when more rows arrive for the same browsing scope, never by falling back to a global ITV cache. Revealing restores the channel's available category and clears search without invoking playback. Provider-neutral queue state belongs in portal/shared/data-access; portal routing, visibility and scrolling remain in their feature libraries.
|
||||
|
||||
**Tech Stack:** Angular signals, Nx/Jest, Playwright, Electron IPC, existing Material icon buttons and translations.
|
||||
|
||||
## Contract
|
||||
|
||||
- Capture the list before asynchronous playback resolution; commit the queue only for the winning successful request. Same active-channel replays/catch-up return keep the queue.
|
||||
- Source/type changes invalidate old queue ownership. Numeric, adjacent and status all read the same queue; remote commands never recapture the browsed category.
|
||||
- A captured paged Stalker queue contains loaded rows. Newly loaded rows extend it only while source/type/category/search scope still matches. No background all-portal crawling; unavailable pages are not advertised as loaded channels.
|
||||
- Xtream uses the actual sidebar sort/search order and filters hidden/removed categories and channels from navigation eligibility. Revealing never unhides categories.
|
||||
- An out-of-filter channel gets a localized `CHANNELS.SHOW_PLAYING_CHANNEL` action in the existing list header. No now-playing title block or EPG changes. Reuse the existing sidebar restore action while collapsed.
|
||||
- Reveal clears interfering query state, selects the active channel's accessible category, waits for rows/rendering and scrolls/focuses the scroll owner. Stale navigation/loading must not reselect a previous channel or restart the player. For paged Stalker search, reuse the already-resolved channel as a scoped normal row until provider results include it, rather than crawling the catalog.
|
||||
- Fullscreen selection captures its own displayed filtered list; existing fullscreen controls and playback/session ownership remain intact. No redesign of the fullscreen panel.
|
||||
- Stalker #1543 remains independent: queue capture consumes the actual list and makes no new global-search policy.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `LiveChannelPlaybackQueue<T>` and focused tests under `libs/portal/shared/data-access/src/lib/`; export via the public barrel. Test capture/fallback, source/type ownership, preserved order, same-scope extension, stale-scope rejection, unchanged-snapshot identity and reset. Run the focused Jest target red, then green.
|
||||
- [x] Extend Xtream live layout and channel-list integration with regression coverage: category/search/sort drift, numeric/status/adjacent parity, hidden category exclusion, reveal without playback calls, route category/query handling, fullscreen list capture. Keep new logic in focused feature files where needed for max-lines. Run `pnpm nx test portal-xtream-feature --runInBand` and lint.
|
||||
- [x] Extend Stalker live layout with the same queue policy and reveal action. Cover ITV/radio ID collisions, asynchronous successful/failed/stale resolution, paged queue extension, cache render windows, category/search drift, and reveal without session reset. Run `pnpm nx test portal-stalker-feature --runInBand` and lint.
|
||||
- [x] Add `CHANNELS.SHOW_PLAYING_CHANNEL` to all shipped locale dictionaries. Keep icon button tooltip and accessible name identical.
|
||||
- [x] Extend closest web and Electron E2E flows. Verify real remote HTTP commands preserve the original queue after browsing changes, and reveal retains the same video/session. Check both portal UIs in light/dark with synthetic sources only.
|
||||
- [x] Update `docs/architecture/remote-control.md`, `stalker-portal.md`, `workspace-shell.md`, and mirrored AGENTS.md/CLAUDE.md guidance. Add one `.changes/portals-live-channel-return.md` note and validate it.
|
||||
- [x] Run affected unit targets, E2E, lint, Electron E2E build, release-note validator and `git diff --check`. Complete independent spec review, then code-quality review and resolve findings.
|
||||
- [ ] Commit, create a PR and complete CI/review checks to ready-to-merge without merging, as authorized in this task's original scope.
|
||||
|
||||
## Validation commands
|
||||
|
||||
```sh
|
||||
pnpm nx test portal-shared-data-access --runInBand
|
||||
pnpm nx test portal-xtream-feature --runInBand
|
||||
pnpm nx test portal-stalker-feature --runInBand
|
||||
pnpm nx run-many -t lint -p portal-shared-data-access,portal-xtream-feature,portal-stalker-feature
|
||||
pnpm nx run electron-backend:build-e2e --parallel=1
|
||||
pnpm run release:notes:validate
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Choose atomized existing web/Electron E2E targets after inspecting project discovery and their runner configuration. Record any environment limitations rather than treating a build or unit pass as full runtime validation.
|
||||
|
||||
## Validation record
|
||||
|
||||
- Stalker data-access after the category-search merge: 546 tests — passed.
|
||||
- Shared data-access: 169 unit tests; Xtream: 445; Stalker: 355 — passed.
|
||||
- Electron build and all three affected library lint targets — passed (existing warnings only).
|
||||
- Electron remote-control suite: 5/5, including both portals and Stalker radio;
|
||||
Chromium category-switch/reveal regression: 1/1 — passed.
|
||||
- New regressions exposed the original queue drift, same-URL reveal cancellation,
|
||||
and virtual viewport attachment race before their fixes.
|
||||
- Independent spec and quality reviews passed after resolving provider-search
|
||||
ownership, beyond-page reveal and delayed-playback page reconciliation.
|
||||
- Release notes and diff whitespace validated. Tested UI in light/dark using
|
||||
synthetic mock portals. Platform-specific Windows/Linux packaged runs and
|
||||
Firefox/WebKit were not needed for the shared renderer/IPC-navigation change.
|
||||
|
||||
- Synced with category-scoped Stalker search (#1552); added auto-open queue regressions for global search/Recently Added after Codex review.
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/collection';
|
||||
export * from './lib/downloads';
|
||||
export * from './lib/multi-source';
|
||||
export * from './lib/live-channel-playback-queue';
|
||||
@@ -0,0 +1,90 @@
|
||||
import { LiveChannelPlaybackQueue } from './live-channel-playback-queue';
|
||||
|
||||
interface Channel {
|
||||
id: string | number | null;
|
||||
name: string;
|
||||
}
|
||||
const alpha: Channel = { id: 1, name: 'Alpha' };
|
||||
const beta: Channel = { id: 2, name: 'Beta' };
|
||||
const gamma: Channel = { id: 3, name: 'Gamma' };
|
||||
|
||||
describe('LiveChannelPlaybackQueue', () => {
|
||||
let queue: LiveChannelPlaybackQueue<Channel>;
|
||||
beforeEach(() => {
|
||||
queue = new LiveChannelPlaybackQueue((item) => item.id);
|
||||
});
|
||||
|
||||
it('captures the displayed order independently of subsequent browsing', () => {
|
||||
const displayed = [beta, alpha];
|
||||
queue.capture('source:itv', 'news:q:sort', displayed, alpha);
|
||||
displayed.reverse();
|
||||
queue.extend('source:itv', 'sports:q:sort', [gamma]);
|
||||
expect(queue.items('source:itv')).toEqual([beta, alpha]);
|
||||
});
|
||||
|
||||
it('does not expose a different source or content type queue', () => {
|
||||
queue.capture('source:itv', 'news', [alpha, beta], alpha);
|
||||
expect(queue.items('source:radio')).toEqual([]);
|
||||
expect(queue.items('other:itv')).toEqual([]);
|
||||
});
|
||||
|
||||
it('extends only the original loaded scope and retains channel numbers', () => {
|
||||
queue.capture('source:itv', 'news', [alpha, beta], alpha);
|
||||
queue.extend('source:itv', 'news', [gamma, beta, alpha]);
|
||||
expect(queue.items('source:itv')).toEqual([alpha, beta, gamma]);
|
||||
});
|
||||
|
||||
it('ignores a late page from a previous owner', () => {
|
||||
queue.capture('source:radio', 'news', [alpha], alpha);
|
||||
queue.extend('source:itv', 'news', [alpha, beta]);
|
||||
expect(queue.items('source:radio')).toEqual([alpha]);
|
||||
});
|
||||
|
||||
it('refreshes metadata without changing existing order', () => {
|
||||
queue.capture('source:itv', 'news', [alpha, beta], alpha);
|
||||
const updatedBeta = { ...beta, name: 'Beta HD' };
|
||||
queue.extend('source:itv', 'news', [alpha, updatedBeta]);
|
||||
expect(queue.items('source:itv')).toEqual([alpha, updatedBeta]);
|
||||
});
|
||||
|
||||
it('does not lose a captured page when the browse resource temporarily clears', () => {
|
||||
queue.capture('source:itv', 'news', [alpha, beta], alpha);
|
||||
queue.extend('source:itv', 'news', []);
|
||||
expect(queue.items('source:itv')).toEqual([alpha, beta]);
|
||||
});
|
||||
|
||||
it('deduplicates normalized IDs and excludes invalid IDs', () => {
|
||||
queue.capture(
|
||||
'source',
|
||||
'news',
|
||||
[alpha, { ...alpha, id: '1' }, { id: null, name: '?' }, beta],
|
||||
alpha
|
||||
);
|
||||
expect(queue.items('source')).toEqual([alpha, beta]);
|
||||
});
|
||||
|
||||
it('falls back to the selected channel, never an unrelated list', () => {
|
||||
queue.capture('source', 'news', [alpha, beta], gamma);
|
||||
expect(queue.items('source')).toEqual([gamma]);
|
||||
});
|
||||
|
||||
it('rejects invalid selected IDs', () => {
|
||||
queue.capture('source', 'news', [alpha], { id: null, name: '?' });
|
||||
expect(queue.items('source')).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves snapshot identity when a page has not changed', () => {
|
||||
queue.capture('source', 'news', [alpha, beta], alpha);
|
||||
const previous = queue.items('source');
|
||||
queue.extend('source', 'news', [alpha, beta]);
|
||||
expect(queue.items('source')).toBe(previous);
|
||||
});
|
||||
|
||||
it('replaces the queue on a new explicit channel selection and clears on teardown', () => {
|
||||
queue.capture('source', 'news', [alpha, beta], alpha);
|
||||
queue.capture('source', 'sports', [gamma], gamma);
|
||||
expect(queue.items('source')).toEqual([gamma]);
|
||||
queue.clear();
|
||||
expect(queue.items('source')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
interface PlaybackQueueSnapshot<T> {
|
||||
readonly owner: string;
|
||||
readonly scope: string;
|
||||
readonly items: readonly T[];
|
||||
}
|
||||
|
||||
/** Component-owned playback order, independent of the list being browsed. */
|
||||
export class LiveChannelPlaybackQueue<T> {
|
||||
private readonly snapshot = signal<PlaybackQueueSnapshot<T> | null>(null);
|
||||
|
||||
constructor(
|
||||
private readonly getId: (item: T) => string | number | null | undefined
|
||||
) {}
|
||||
|
||||
items(owner: string): readonly T[] {
|
||||
const snapshot = this.snapshot();
|
||||
return snapshot?.owner === owner ? snapshot.items : [];
|
||||
}
|
||||
|
||||
capture(
|
||||
owner: string,
|
||||
scope: string,
|
||||
items: readonly T[],
|
||||
active: T
|
||||
): void {
|
||||
const activeId = this.key(active);
|
||||
if (!activeId) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
const unique = this.unique(items);
|
||||
this.snapshot.set({
|
||||
owner,
|
||||
scope,
|
||||
items: unique.has(activeId) ? [...unique.values()] : [active],
|
||||
});
|
||||
}
|
||||
|
||||
/** Append loaded pages only for the scope that supplied this queue. */
|
||||
extend(owner: string, scope: string, items: readonly T[]): void {
|
||||
const previous = this.snapshot();
|
||||
if (!previous || previous.owner !== owner || previous.scope !== scope)
|
||||
return;
|
||||
const incoming = this.unique(items);
|
||||
const next = previous.items.map((item) => {
|
||||
const id = this.key(item);
|
||||
const current = incoming.get(id) ?? item;
|
||||
incoming.delete(id);
|
||||
return current;
|
||||
});
|
||||
next.push(...incoming.values());
|
||||
if (
|
||||
next.length === previous.items.length &&
|
||||
next.every((item, index) => item === previous.items[index])
|
||||
)
|
||||
return;
|
||||
this.snapshot.set({ ...previous, items: next });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.snapshot.set(null);
|
||||
}
|
||||
|
||||
private key(item: T): string {
|
||||
const id = this.getId(item);
|
||||
return id == null ? '' : String(id);
|
||||
}
|
||||
|
||||
private unique(items: readonly T[]): Map<string, T> {
|
||||
const result = new Map<string, T>();
|
||||
for (const item of items) {
|
||||
const id = this.key(item);
|
||||
if (id && !result.has(id)) result.set(id, item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+124
@@ -172,6 +172,42 @@ describe('withStalkerContent failure states', () => {
|
||||
store = TestBed.inject(TestContentStore);
|
||||
});
|
||||
|
||||
it('keeps ITV pages unfiltered while independent local searches change', async () => {
|
||||
const pending =
|
||||
createDeferred<ReturnType<typeof createContentResponse>>();
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event, request: { params: { action: string } }) =>
|
||||
request.params.action === 'get_genres'
|
||||
? Promise.resolve({ js: [] })
|
||||
: pending.promise
|
||||
);
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCategories('itv', [
|
||||
{ category_id: '5', category_name: 'Five' },
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
patchState(store, { searchPhrase: 'sidebar' });
|
||||
await flushResources();
|
||||
const orderedCalls = () =>
|
||||
dataService.sendIpcEvent.mock.calls.filter(
|
||||
(call) =>
|
||||
(call[1] as { params: { action: string } }).params
|
||||
.action === 'get_ordered_list'
|
||||
);
|
||||
await waitForCondition(() => orderedCalls().length > 0);
|
||||
expect(
|
||||
(orderedCalls()[0][1] as { params: { search?: string } }).params
|
||||
.search
|
||||
).toBeUndefined();
|
||||
patchState(store, { searchPhrase: 'changed' });
|
||||
await flushResources();
|
||||
pending.resolve(createContentResponse('Panel match'));
|
||||
await waitForCondition(() => store.itvChannels().length > 0);
|
||||
expect(orderedCalls()).toHaveLength(1);
|
||||
expect(store.itvChannels()[0].name).toBe('Panel match');
|
||||
});
|
||||
|
||||
it('normalizes category failures into empty arrays and explicit error state', async () => {
|
||||
dataService.sendIpcEvent.mockRejectedValue(
|
||||
new Error('get_genres failed')
|
||||
@@ -724,6 +760,15 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
void store.isPaginatedContentLoading();
|
||||
}
|
||||
|
||||
it('does not request paged channels without a selected category', async () => {
|
||||
setup(null);
|
||||
enterItvCategory('');
|
||||
await flushResources();
|
||||
expect(store.itvChannels()).toEqual([]);
|
||||
expect(store.hasMoreChannels()).toBe(false);
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves the whole category from the cache without portal requests', async () => {
|
||||
setup(CACHED_CHANNELS);
|
||||
enterItvCategory('5');
|
||||
@@ -897,6 +942,85 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
expect(store.hasMoreChannels()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps censored pagination after a delayed cache replays the current page', async () => {
|
||||
setup(null);
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event, payload: { params: { p: number } }) =>
|
||||
Promise.resolve({
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: String(payload.params.p),
|
||||
name: `Hidden ${payload.params.p}`,
|
||||
},
|
||||
],
|
||||
total_items: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
enterItvCategory('1099');
|
||||
await waitForCondition(() => store.itvChannels().length === 1);
|
||||
store.setPage(1);
|
||||
await waitForCondition(() => store.itvChannels().length === 2);
|
||||
itvCache.setChannels(CACHED_CHANNELS);
|
||||
await waitForCondition(
|
||||
() => dataService.sendIpcEvent.mock.calls.length === 3
|
||||
);
|
||||
await flushResources();
|
||||
expect(store.itvChannels().map((row) => row.name)).toEqual([
|
||||
'Hidden 1',
|
||||
'Hidden 2',
|
||||
]);
|
||||
expect(store.hasMoreChannels()).toBe(true);
|
||||
store.setPage(2);
|
||||
await waitForCondition(() => store.itvChannels().length === 3);
|
||||
expect(store.hasMoreChannels()).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['empty', 'repeated'])(
|
||||
'stops uncached search pagination on an %s page',
|
||||
async (ending) => {
|
||||
setup(CACHED_CHANNELS);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { data: [{ id: 'one', name: 'Hidden' }], total_items: 99 },
|
||||
});
|
||||
enterItvCategory('1099');
|
||||
await waitForCondition(() => store.itvChannels().length === 1);
|
||||
if (ending === 'empty')
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { data: [], total_items: 99 },
|
||||
});
|
||||
store.setPage(1);
|
||||
await waitForCondition(() => !store.hasMoreChannels());
|
||||
expect(store.itvChannels()).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an abandoned category response even after navigating back to it', async () => {
|
||||
setup(null);
|
||||
const old = createDeferred<unknown>();
|
||||
dataService.sendIpcEvent.mockReturnValueOnce(old.promise);
|
||||
enterItvCategory('5');
|
||||
await waitForCondition(
|
||||
() => dataService.sendIpcEvent.mock.calls.length === 1
|
||||
);
|
||||
dataService.sendIpcEvent.mockResolvedValue(
|
||||
createContentResponse('Current')
|
||||
);
|
||||
store.setSelectedCategory('9');
|
||||
await waitForCondition(
|
||||
() => store.itvChannels()[0]?.name === 'Current'
|
||||
);
|
||||
store.setSelectedCategory('5');
|
||||
await waitForCondition(
|
||||
() => dataService.sendIpcEvent.mock.calls.length === 3
|
||||
);
|
||||
await flushResources();
|
||||
old.resolve(createContentResponse('Abandoned'));
|
||||
await flushResources();
|
||||
expect(store.itvChannels()[0].name).toBe('Current');
|
||||
});
|
||||
|
||||
it('kicks off a background full-list load and swaps it in once ready', async () => {
|
||||
setup(null);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
|
||||
+31
-6
@@ -235,6 +235,7 @@ export function withStalkerContent() {
|
||||
portalRepair,
|
||||
};
|
||||
|
||||
let lastLivePageKey = '';
|
||||
return {
|
||||
categoryResource: resource({
|
||||
params: () => ({
|
||||
@@ -360,7 +361,12 @@ export function withStalkerContent() {
|
||||
params: () => ({
|
||||
contentType: storeContext.selectedContentType(),
|
||||
category: storeContext.selectedCategoryId(),
|
||||
search: storeContext.searchPhrase(),
|
||||
// ITV fields filter locally; server search would narrow
|
||||
// the shared pages behind the independent fullscreen field.
|
||||
search:
|
||||
storeContext.selectedContentType() === 'itv'
|
||||
? ''
|
||||
: storeContext.searchPhrase(),
|
||||
pageIndex: storeContext.page() + 1,
|
||||
currentPlaylist: storeContext.currentPlaylist(),
|
||||
// Re-fires the loader once THIS portal's full ITV
|
||||
@@ -384,6 +390,7 @@ export function withStalkerContent() {
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
abortSignal,
|
||||
}): Promise<StalkerContentItem[]> => {
|
||||
if (!params.category || params.category === '') {
|
||||
patchState(
|
||||
@@ -477,12 +484,14 @@ export function withStalkerContent() {
|
||||
null;
|
||||
|
||||
return (
|
||||
!abortSignal.aborted &&
|
||||
params.contentType ===
|
||||
storeContext.selectedContentType() &&
|
||||
params.category ===
|
||||
storeContext.selectedCategoryId() &&
|
||||
params.search ===
|
||||
storeContext.searchPhrase() &&
|
||||
(params.contentType === 'itv' ||
|
||||
params.search ===
|
||||
storeContext.searchPhrase()) &&
|
||||
params.pageIndex ===
|
||||
storeContext.page() + 1 &&
|
||||
paramsPlaylistKey === currentPlaylistKey &&
|
||||
@@ -593,11 +602,22 @@ export function withStalkerContent() {
|
||||
const nextChannels =
|
||||
params.pageIndex === 1
|
||||
? channels
|
||||
: [
|
||||
: dedupeContentById([
|
||||
...existingChannels,
|
||||
...channels,
|
||||
];
|
||||
]).map(toStalkerItvChannel);
|
||||
|
||||
const livePageKey = JSON.stringify([
|
||||
paramsPlaylistKey,
|
||||
params.contentType,
|
||||
params.category,
|
||||
params.pageIndex,
|
||||
]);
|
||||
// Cache readiness can replay the current censored page.
|
||||
// A different page adding no ids is a stalled portal.
|
||||
const replay =
|
||||
livePageKey === lastLivePageKey;
|
||||
lastLivePageKey = livePageKey;
|
||||
patchState(store, {
|
||||
totalCount:
|
||||
response.js.total_items ?? 0,
|
||||
@@ -607,8 +627,13 @@ export function withStalkerContent() {
|
||||
? { itvChannels: nextChannels }
|
||||
: { radioChannels: nextChannels }),
|
||||
hasMoreChannels:
|
||||
channels.length > 0 &&
|
||||
(params.pageIndex === 1 ||
|
||||
replay ||
|
||||
nextChannels.length >
|
||||
existingChannels.length) &&
|
||||
nextChannels.length <
|
||||
(response.js.total_items ?? 0),
|
||||
(response.js.total_items ?? 0),
|
||||
});
|
||||
} else {
|
||||
// VOD/series pages accumulate into one
|
||||
|
||||
+9
@@ -57,6 +57,15 @@ describe('withStalkerSelection', () => {
|
||||
expect(store.page()).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves ITV pages when a local search changes or clears', () => {
|
||||
store.setSelectedContentType('itv');
|
||||
store.setPage(2);
|
||||
store.setSearchPhrase('shared');
|
||||
expect(store.page()).toBe(2);
|
||||
store.setSearchPhrase('');
|
||||
expect(store.page()).toBe(2);
|
||||
});
|
||||
|
||||
it('synchronizes entity ids when the selected item changes', () => {
|
||||
store.setSelectedItem({
|
||||
id: '55',
|
||||
|
||||
+5
-1
@@ -94,7 +94,11 @@ export function withStalkerSelection() {
|
||||
return;
|
||||
}
|
||||
|
||||
patchState(store, { searchPhrase: phrase, page: 0 });
|
||||
// ITV search is local to each surface; keep the shared catalog pages.
|
||||
patchState(store, {
|
||||
searchPhrase: phrase,
|
||||
...(store.selectedContentType() === 'itv' ? {} : { page: 0 }),
|
||||
});
|
||||
},
|
||||
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
|
||||
const selectedIdRaw =
|
||||
|
||||
+4
@@ -158,6 +158,10 @@ describe('shouldAutoFillStampedList', () => {
|
||||
expect(shouldAutoFillStampedList(false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('fills an ITV sidebar search to reach matches on later pages', () => {
|
||||
expect(shouldAutoFillStampedList(false, true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('always lets the fullscreen panel fill itself', () => {
|
||||
// The sidebar's search term is not the panel's concern: the panel's
|
||||
// own term is what may need the next page.
|
||||
|
||||
+7
-8
@@ -18,17 +18,16 @@ export type PanelSearchScrollAction = 'idle' | 'grow-window' | 'load-page';
|
||||
*/
|
||||
/**
|
||||
* Whether a stamped list that does not overflow should fill itself without a
|
||||
* scroll. The sidebar copy never does so while its own search is active
|
||||
* (scrolling it still pages) — that has always been its behaviour, since a
|
||||
* client-side search would otherwise walk the whole portal on its own. The
|
||||
* fullscreen panel's copy always does: its term is what may need the next
|
||||
* page, and the sidebar's term is not its concern.
|
||||
* scroll. ITV local search must reach later category pages when there are
|
||||
* no matches to scroll. Radio keeps its server-search pagination behavior.
|
||||
* The fullscreen copy fills independently of the sidebar's term.
|
||||
*/
|
||||
export function shouldAutoFillStampedList(
|
||||
isPanelContainer: boolean,
|
||||
sidebarSearchActive: boolean
|
||||
sidebarSearchActive: boolean,
|
||||
completeLocalSearch = false
|
||||
): boolean {
|
||||
return isPanelContainer || !sidebarSearchActive;
|
||||
return isPanelContainer || !sidebarSearchActive || completeLocalSearch;
|
||||
}
|
||||
|
||||
export function resolvePanelSearchScroll(state: {
|
||||
@@ -48,7 +47,7 @@ export function resolvePanelSearchScroll(state: {
|
||||
* Memoized, windowed matches for the fullscreen channel panel's own search
|
||||
* field.
|
||||
*
|
||||
* In full-list mode the search source is the portal's entire channel list,
|
||||
* The search source is the selected category (the portal in All Items),
|
||||
* and a broad term ("tv") matches most of it. The panel renders one
|
||||
* `app-channel-list-item` per row without virtual scrolling, so the matches
|
||||
* are rendered through the same bounded window the sidebar uses: `chunk`
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerItvChannel,
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerLiveNavigation } from './stalker-live-navigation';
|
||||
|
||||
const channel = (id: string, category = 'news'): StalkerItvChannel => ({
|
||||
id,
|
||||
name: id,
|
||||
cmd: `stream-${id}`,
|
||||
tv_genre_id: category,
|
||||
});
|
||||
|
||||
describe('StalkerLiveNavigation', () => {
|
||||
let navigation: StalkerLiveNavigation;
|
||||
const first = channel('1');
|
||||
const second = channel('2');
|
||||
const third = channel('3', 'sports');
|
||||
const rows = signal([first, second]);
|
||||
const loading = signal(false);
|
||||
const store = {
|
||||
currentPlaylist: signal({ _id: 'source-a' }),
|
||||
selectedContentType: signal('itv'),
|
||||
selectedCategoryId: signal<string | null>('news'),
|
||||
searchPhrase: signal(''),
|
||||
selectedItem: signal<StalkerItvChannel | null>(null),
|
||||
page: signal(0),
|
||||
hasMoreChannels: signal(false),
|
||||
getCategoryResource: signal([
|
||||
{ category_id: 'news' },
|
||||
{ category_id: 'sports' },
|
||||
]),
|
||||
setSearchPhrase: jest.fn((query: string) =>
|
||||
store.searchPhrase.set(query)
|
||||
),
|
||||
setSelectedCategory: jest.fn((category: string) =>
|
||||
store.selectedCategoryId.set(category)
|
||||
),
|
||||
setPage: jest.fn((page: number) => store.page.set(page)),
|
||||
};
|
||||
const router = {
|
||||
url: '/workspace/stalker/source-a/itv?q=sport&keep=1',
|
||||
parseUrl: jest.fn(() => ({ queryParams: { q: 'sport', keep: '1' } })),
|
||||
navigateByUrl: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const sidebar = { setState: jest.fn() };
|
||||
const play = jest.fn();
|
||||
const revealRow = jest.fn(() => true);
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
jest.clearAllMocks();
|
||||
rows.set([first, second]);
|
||||
loading.set(false);
|
||||
store.currentPlaylist.set({ _id: 'source-a' });
|
||||
store.selectedContentType.set('itv');
|
||||
store.selectedCategoryId.set('news');
|
||||
store.searchPhrase.set('');
|
||||
store.selectedItem.set(null);
|
||||
store.page.set(0);
|
||||
store.hasMoreChannels.set(false);
|
||||
store.getCategoryResource.set([
|
||||
{ category_id: 'news' },
|
||||
{ category_id: 'sports' },
|
||||
]);
|
||||
router.navigateByUrl.mockResolvedValue(true);
|
||||
navigation = TestBed.runInInjectionContext(
|
||||
() =>
|
||||
new StalkerLiveNavigation({
|
||||
store: store as unknown as InstanceType<
|
||||
typeof StalkerStore
|
||||
>,
|
||||
router: router as unknown as Router,
|
||||
sidebar:
|
||||
sidebar as unknown as LiveLayoutSidebarStateService,
|
||||
rows: (term) => {
|
||||
const query = term?.() ?? store.searchPhrase();
|
||||
return rows().filter(
|
||||
(row) => !query || row.name?.includes(query)
|
||||
);
|
||||
},
|
||||
loading,
|
||||
play,
|
||||
revealRow,
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
});
|
||||
afterEach(() => navigation.reset());
|
||||
|
||||
it('captures resolved selection order and keeps it during category/search browsing', () => {
|
||||
const commit = navigation.prepare(first);
|
||||
store.selectedCategoryId.set('sports');
|
||||
rows.set([third]);
|
||||
commit();
|
||||
store.searchPhrase.set('3');
|
||||
TestBed.tick();
|
||||
expect(navigation.channels()).toEqual([first, second]);
|
||||
navigation.adjacent('down');
|
||||
expect(play).toHaveBeenLastCalledWith(second);
|
||||
navigation.selectNumber(1);
|
||||
expect(play).toHaveBeenLastCalledWith(first);
|
||||
expect(store.selectedCategoryId()).toBe('sports');
|
||||
});
|
||||
|
||||
it('does not replace playback navigation for a failed resolution', () => {
|
||||
navigation.prepare(first)();
|
||||
rows.set([third]);
|
||||
navigation.prepare(third); // Rejected resolver never calls the commit.
|
||||
navigation.adjacent('down');
|
||||
expect(play).toHaveBeenCalledWith(second);
|
||||
});
|
||||
|
||||
it('preserves the queue on remote selection and same-channel replay', () => {
|
||||
navigation.prepare(first)();
|
||||
rows.set([third]);
|
||||
navigation.prepare(second, 'preserve')();
|
||||
navigation.prepare(second)();
|
||||
navigation.selectNumber(1);
|
||||
expect(play).toHaveBeenCalledWith(first);
|
||||
});
|
||||
|
||||
it('captures the fullscreen filtered list independently of sidebar search', () => {
|
||||
store.searchPhrase.set('1');
|
||||
rows.set([second, first]);
|
||||
navigation.prepare(second, signal(''))();
|
||||
expect(navigation.channels()).toEqual([second, first]);
|
||||
});
|
||||
|
||||
it('extends only loaded pages of the original category and query', () => {
|
||||
navigation.prepare(first)();
|
||||
rows.set([first, second, channel('4')]);
|
||||
store.page.set(1);
|
||||
TestBed.tick();
|
||||
expect(navigation.channels().map((item) => item.id)).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'4',
|
||||
]);
|
||||
store.searchPhrase.set('5');
|
||||
rows.set([channel('5')]);
|
||||
TestBed.tick();
|
||||
store.searchPhrase.set('');
|
||||
store.selectedCategoryId.set('sports');
|
||||
rows.set([third]);
|
||||
TestBed.tick();
|
||||
expect(navigation.channels().map((item) => item.id)).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not append another provider search after a fullscreen capture', () => {
|
||||
store.selectedContentType.set('radio');
|
||||
TestBed.tick();
|
||||
store.searchPhrase.set('1');
|
||||
navigation.prepare(first, signal(''))();
|
||||
store.searchPhrase.set('3');
|
||||
rows.set([third]);
|
||||
TestBed.tick();
|
||||
expect(navigation.channels()).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('includes same-scope pages that finish while playback resolution is pending', () => {
|
||||
const commit = navigation.prepare(first);
|
||||
const nextPage = channel('4');
|
||||
rows.set([first, second, nextPage]);
|
||||
TestBed.tick();
|
||||
commit();
|
||||
TestBed.tick();
|
||||
expect(navigation.channels()).toEqual([first, second, nextPage]);
|
||||
});
|
||||
|
||||
it('rejects late commits from a different source or content type and after reset', () => {
|
||||
const commit = navigation.prepare(first);
|
||||
store.selectedContentType.set('radio');
|
||||
commit();
|
||||
rows.set([third]);
|
||||
TestBed.tick();
|
||||
expect(navigation.channels()).toEqual([third]);
|
||||
const radioCommit = navigation.prepare(third);
|
||||
navigation.reset();
|
||||
rows.set([]);
|
||||
radioCommit();
|
||||
expect(navigation.channels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reveals in the original category, clears only q and never plays', fakeAsync(() => {
|
||||
navigation.prepare(first)();
|
||||
store.selectedCategoryId.set('sports');
|
||||
store.searchPhrase.set('3');
|
||||
rows.set([third]);
|
||||
expect(navigation.canReveal()).toBe(true);
|
||||
loading.set(true);
|
||||
void navigation.reveal();
|
||||
tick();
|
||||
rows.set([first, second]);
|
||||
loading.set(false);
|
||||
TestBed.tick();
|
||||
tick();
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(
|
||||
{ queryParams: { keep: '1' } },
|
||||
{ replaceUrl: true }
|
||||
);
|
||||
expect(store.selectedCategoryId()).toBe('news');
|
||||
expect(store.searchPhrase()).toBe('');
|
||||
expect(sidebar.setState).toHaveBeenCalledWith('expanded');
|
||||
expect(revealRow).toHaveBeenCalledWith('1');
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(navigation.canReveal()).toBe(false);
|
||||
}));
|
||||
|
||||
it('withholds reveal for removed categories', () => {
|
||||
navigation.prepare(first)();
|
||||
rows.set([third]);
|
||||
store.getCategoryResource.set([{ category_id: 'sports' }]);
|
||||
expect(navigation.canReveal()).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels reveal if browsing changes while routing is pending', async () => {
|
||||
navigation.prepare(first)();
|
||||
store.selectedCategoryId.set('sports');
|
||||
let resolve!: (value: boolean) => void;
|
||||
router.navigateByUrl.mockReturnValue(
|
||||
new Promise<boolean>((done) => (resolve = done))
|
||||
);
|
||||
const pending = navigation.reveal();
|
||||
store.selectedCategoryId.set('another');
|
||||
resolve(true);
|
||||
await pending;
|
||||
expect(store.setSelectedCategory).not.toHaveBeenCalled();
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals the known row beyond loaded pages and deduplicates when it arrives', fakeAsync(() => {
|
||||
store.searchPhrase.set('2');
|
||||
rows.set([second]);
|
||||
navigation.prepare(second)();
|
||||
store.selectedCategoryId.set('sports');
|
||||
rows.set([third]);
|
||||
void navigation.reveal();
|
||||
tick();
|
||||
rows.set([first]);
|
||||
TestBed.tick();
|
||||
tick();
|
||||
expect(navigation.withRevealedItem(rows())).toEqual([second, first]);
|
||||
expect(revealRow).toHaveBeenCalledWith('2');
|
||||
rows.set([first, second]);
|
||||
expect(navigation.withRevealedItem(rows())).toEqual([first, second]);
|
||||
expect(navigation.channels()).toEqual([second]);
|
||||
store.selectedCategoryId.set('sports');
|
||||
TestBed.tick();
|
||||
store.selectedCategoryId.set('news');
|
||||
rows.set([first]);
|
||||
expect(navigation.withRevealedItem(rows())).toEqual([first]);
|
||||
}));
|
||||
|
||||
it('returns within the same route without navigating when q is absent', async () => {
|
||||
navigation.prepare(first)();
|
||||
store.selectedCategoryId.set('sports');
|
||||
router.parseUrl.mockReturnValueOnce({ queryParams: {} } as ReturnType<
|
||||
typeof router.parseUrl
|
||||
>);
|
||||
await navigation.reveal();
|
||||
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||
expect(store.selectedCategoryId()).toBe('news');
|
||||
});
|
||||
});
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
signal,
|
||||
untracked,
|
||||
type Signal,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LiveChannelPlaybackQueue } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
getAdjacentChannelItem,
|
||||
getChannelItemByNumber,
|
||||
LiveLayoutSidebarStateService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerItvChannel,
|
||||
StalkerStore,
|
||||
normalizeStalkerEntityId,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
type SelectionContext = Signal<string> | 'preserve' | undefined;
|
||||
interface LiveNavigationOptions {
|
||||
store: InstanceType<typeof StalkerStore>;
|
||||
router: Router | null;
|
||||
sidebar: LiveLayoutSidebarStateService;
|
||||
rows: (panelTerm?: Signal<string>) => StalkerItvChannel[];
|
||||
play: (item: StalkerItvChannel) => void;
|
||||
revealRow: (id: string) => boolean;
|
||||
loading: () => boolean;
|
||||
}
|
||||
interface PlayingOrigin {
|
||||
owner: string;
|
||||
item: StalkerItvChannel;
|
||||
categories: ReadonlyMap<string, string>;
|
||||
scope: string;
|
||||
}
|
||||
interface RevealRequest {
|
||||
owner: string;
|
||||
id: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/** Owns live navigation without letting category browsing replace playback order. */
|
||||
export class StalkerLiveNavigation {
|
||||
private readonly queue = new LiveChannelPlaybackQueue<StalkerItvChannel>(
|
||||
(item) => item.id
|
||||
);
|
||||
private readonly origin = signal<PlayingOrigin | null>(null);
|
||||
private readonly pendingReveal = signal<RevealRequest | null>(null);
|
||||
private readonly revealed = signal<{
|
||||
owner: string;
|
||||
category: string;
|
||||
item: StalkerItvChannel;
|
||||
} | null>(null);
|
||||
private readonly panelTerm = signal<Signal<string> | undefined>(undefined);
|
||||
private ownerSeen = '';
|
||||
private generation = 0;
|
||||
private revealGeneration = 0;
|
||||
private revealTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
readonly owner = computed(() =>
|
||||
JSON.stringify([
|
||||
this.options.store.currentPlaylist()?._id,
|
||||
this.options.store.selectedContentType(),
|
||||
])
|
||||
);
|
||||
readonly channels = computed(() => {
|
||||
const captured = this.queue.items(this.owner());
|
||||
return captured.length ? [...captured] : this.options.rows();
|
||||
});
|
||||
readonly canReveal = computed(() => {
|
||||
const active = this.activeItem();
|
||||
return (
|
||||
!!active &&
|
||||
!!this.categoryFor(active) &&
|
||||
!this.withRevealedItem(this.options.rows()).some(
|
||||
(row) => this.id(row) === this.id(active)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
constructor(private readonly options: LiveNavigationOptions) {
|
||||
effect(() => {
|
||||
const owner = this.owner();
|
||||
if (owner !== this.ownerSeen) {
|
||||
this.ownerSeen = owner;
|
||||
untracked(() => this.reset());
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
const owner = this.owner();
|
||||
const scope = this.scope(this.panelTerm());
|
||||
const rows = this.options.rows(this.panelTerm());
|
||||
const origin = this.origin();
|
||||
if (this.options.loading()) return;
|
||||
untracked(() => {
|
||||
this.queue.extend(owner, scope, rows);
|
||||
if (origin?.owner === owner && origin.scope === scope) {
|
||||
const categories = new Map(origin.categories);
|
||||
for (const row of rows)
|
||||
categories.set(
|
||||
this.id(row),
|
||||
String(
|
||||
row.tv_genre_id ??
|
||||
row.category_id ??
|
||||
this.options.store.selectedCategoryId() ??
|
||||
'*'
|
||||
)
|
||||
);
|
||||
if (categories.size > origin.categories.size)
|
||||
this.origin.set({
|
||||
...origin,
|
||||
categories,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
const revealed = this.revealed();
|
||||
if (
|
||||
revealed &&
|
||||
(revealed.owner !== this.owner() ||
|
||||
revealed.category !==
|
||||
this.options.store.selectedCategoryId() ||
|
||||
this.options.store.searchPhrase() ||
|
||||
this.id(revealed.item) !== this.id(this.activeItem()))
|
||||
)
|
||||
this.revealed.set(null);
|
||||
});
|
||||
effect(() => {
|
||||
const request = this.pendingReveal();
|
||||
if (!request) return;
|
||||
const owner = this.owner();
|
||||
const category = this.options.store.selectedCategoryId();
|
||||
const query = this.options.store.searchPhrase();
|
||||
this.options.rows();
|
||||
const loading = this.options.loading();
|
||||
untracked(() => {
|
||||
if (
|
||||
owner !== request.owner ||
|
||||
category !== request.category ||
|
||||
query ||
|
||||
this.id(this.activeItem()) !== request.id
|
||||
) {
|
||||
this.pendingReveal.set(null);
|
||||
return;
|
||||
}
|
||||
if (loading) return;
|
||||
if (this.revealTimer) clearTimeout(this.revealTimer);
|
||||
// Render after the category resource and its reset effect settle.
|
||||
this.revealTimer = setTimeout(() => {
|
||||
if (
|
||||
this.pendingReveal() !== request ||
|
||||
this.options.loading()
|
||||
)
|
||||
return;
|
||||
if (
|
||||
this.id(this.activeItem()) !== request.id ||
|
||||
this.owner() !== request.owner ||
|
||||
this.options.store.selectedCategoryId() !==
|
||||
request.category ||
|
||||
this.options.store.searchPhrase()
|
||||
) {
|
||||
this.pendingReveal.set(null);
|
||||
return;
|
||||
}
|
||||
if (this.options.revealRow(request.id))
|
||||
this.pendingReveal.set(null);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture before resolution; only the winning playback request commits it. */
|
||||
prepare(item: StalkerItvChannel, context?: SelectionContext): () => void {
|
||||
const owner = this.owner();
|
||||
const generation = this.generation;
|
||||
const previous = this.origin();
|
||||
const keep =
|
||||
context === 'preserve' ||
|
||||
(previous?.owner === owner &&
|
||||
this.id(previous.item) === this.id(item));
|
||||
const term = typeof context === 'function' ? context : undefined;
|
||||
const rows = term
|
||||
? [...this.options.rows(term)]
|
||||
: this.withRevealedItem(this.options.rows());
|
||||
const scope = this.scope(term);
|
||||
const category = this.options.store.selectedCategoryId() ?? '*';
|
||||
const categories =
|
||||
keep && previous?.owner === owner
|
||||
? previous.categories
|
||||
: new Map(
|
||||
rows
|
||||
.concat(item)
|
||||
.map((row) => [
|
||||
this.id(row),
|
||||
String(
|
||||
row.tv_genre_id ?? row.category_id ?? category
|
||||
),
|
||||
])
|
||||
);
|
||||
return () => {
|
||||
if (owner !== this.owner() || generation !== this.generation)
|
||||
return;
|
||||
if (!keep || !this.queue.items(owner).length) {
|
||||
this.queue.capture(owner, scope, rows, item);
|
||||
this.panelTerm.set(term);
|
||||
}
|
||||
this.revealed.set(null);
|
||||
this.pendingReveal.set(null);
|
||||
this.origin.set({
|
||||
owner,
|
||||
item,
|
||||
categories,
|
||||
scope: keep ? (previous?.scope ?? scope) : scope,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
adjacent(direction: 'up' | 'down'): void {
|
||||
const next = getAdjacentChannelItem(
|
||||
this.channels(),
|
||||
this.activeItem()?.id,
|
||||
direction,
|
||||
(item) => item.id
|
||||
);
|
||||
if (next) this.options.play(next);
|
||||
}
|
||||
|
||||
selectNumber(number?: number): void {
|
||||
if (!number) return;
|
||||
const channel = getChannelItemByNumber(this.channels(), number);
|
||||
if (channel) this.options.play(channel);
|
||||
}
|
||||
|
||||
async reveal(): Promise<void> {
|
||||
const active = this.activeItem();
|
||||
const category = active && this.categoryFor(active);
|
||||
if (!active || !category) return;
|
||||
const owner = this.owner();
|
||||
const generation = ++this.revealGeneration;
|
||||
const browsedCategory = this.options.store.selectedCategoryId();
|
||||
this.pendingReveal.set(null);
|
||||
if (
|
||||
this.options.router &&
|
||||
this.options.router.parseUrl(this.options.router.url).queryParams[
|
||||
'q'
|
||||
] != null
|
||||
) {
|
||||
const tree = this.options.router.parseUrl(this.options.router.url);
|
||||
delete tree.queryParams['q'];
|
||||
const navigated = await this.options.router
|
||||
.navigateByUrl(tree, {
|
||||
replaceUrl: true,
|
||||
})
|
||||
.catch(() => false);
|
||||
if (
|
||||
!navigated ||
|
||||
generation !== this.revealGeneration ||
|
||||
owner !== this.owner() ||
|
||||
this.id(active) !== this.id(this.activeItem()) ||
|
||||
browsedCategory !== this.options.store.selectedCategoryId() ||
|
||||
category !== this.categoryFor(active)
|
||||
)
|
||||
return;
|
||||
}
|
||||
this.options.store.setSearchPhrase('');
|
||||
this.options.store.setSelectedCategory(category);
|
||||
this.options.store.setPage(0);
|
||||
this.options.sidebar.setState('expanded');
|
||||
this.revealed.set({ owner, category, item: active });
|
||||
this.pendingReveal.set({
|
||||
owner,
|
||||
category,
|
||||
id: this.id(active),
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.generation++;
|
||||
this.revealGeneration++;
|
||||
this.revealed.set(null);
|
||||
this.queue.clear();
|
||||
this.origin.set(null);
|
||||
this.pendingReveal.set(null);
|
||||
this.panelTerm.set(undefined);
|
||||
if (this.revealTimer) clearTimeout(this.revealTimer);
|
||||
}
|
||||
|
||||
/** A known playing row keeps return bounded on provider-paginated search. */
|
||||
withRevealedItem(rows: StalkerItvChannel[]): StalkerItvChannel[] {
|
||||
const revealed = this.revealed();
|
||||
if (
|
||||
!revealed ||
|
||||
revealed.owner !== this.owner() ||
|
||||
revealed.category !== this.options.store.selectedCategoryId() ||
|
||||
this.options.store.searchPhrase() ||
|
||||
this.id(revealed.item) !== this.id(this.activeItem()) ||
|
||||
!this.categoryFor(revealed.item) ||
|
||||
rows.some((row) => this.id(row) === this.id(revealed.item))
|
||||
)
|
||||
return rows;
|
||||
return [revealed.item, ...rows];
|
||||
}
|
||||
|
||||
private scope(panelTerm?: Signal<string>): string {
|
||||
return JSON.stringify([
|
||||
this.options.store.selectedCategoryId(),
|
||||
panelTerm ? 'panel' : 'sidebar',
|
||||
this.options.store.selectedContentType() === 'radio'
|
||||
? this.options.store.searchPhrase()
|
||||
: '',
|
||||
panelTerm?.() ?? this.options.store.searchPhrase(),
|
||||
]);
|
||||
}
|
||||
|
||||
activeItem(): StalkerItvChannel | undefined {
|
||||
const origin = this.origin();
|
||||
return origin?.owner === this.owner()
|
||||
? origin.item
|
||||
: (this.options.store.selectedItem() as
|
||||
StalkerItvChannel | undefined);
|
||||
}
|
||||
|
||||
private categoryFor(item: StalkerItvChannel): string | undefined {
|
||||
const origin = this.origin();
|
||||
const category = String(
|
||||
item.tv_genre_id ??
|
||||
item.category_id ??
|
||||
(origin?.owner === this.owner()
|
||||
? origin.categories.get(this.id(item))
|
||||
: '') ??
|
||||
''
|
||||
);
|
||||
return this.options.store
|
||||
.getCategoryResource?.()
|
||||
.some((entry) => String(entry.category_id) === category)
|
||||
? category
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private id(item: StalkerItvChannel | null | undefined): string {
|
||||
return normalizeStalkerEntityId(item?.id);
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -27,6 +27,19 @@
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (navigation.canReveal()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
'CHANNELS.SHOW_PLAYING_CHANNEL' | translate
|
||||
"
|
||||
[matTooltip]="'CHANNELS.SHOW_PLAYING_CHANNEL' | translate"
|
||||
(click)="navigation.reveal()"
|
||||
>
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (isFullListMode()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
@@ -264,6 +277,7 @@
|
||||
track item.id ?? $index
|
||||
) {
|
||||
<app-channel-list-item
|
||||
[attr.data-channel-id]="normalizeStalkerEntityId(item.id)"
|
||||
[name]="item.o_name || item.name || ''"
|
||||
[logo]="item.logo"
|
||||
[selected]="isSelectedChannel(item)"
|
||||
@@ -292,9 +306,11 @@
|
||||
favorites.get(normalizeStalkerEntityId(item.id)) ??
|
||||
false
|
||||
"
|
||||
(clicked)="playChannel(item)"
|
||||
(activated)="playChannel(item, true)"
|
||||
(keyboardActivated)="playChannel(item, true)"
|
||||
(clicked)="playChannel(item, undefined, panelSearchTerm)"
|
||||
(activated)="playChannel(item, true, panelSearchTerm)"
|
||||
(keyboardActivated)="
|
||||
playChannel(item, true, panelSearchTerm)
|
||||
"
|
||||
(favoriteToggled)="toggleFavorite(item)"
|
||||
(contextMenuRequested)="onChannelContextMenu(item, $event)"
|
||||
/>
|
||||
|
||||
+9
-8
@@ -729,7 +729,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('searches the whole portal (all categories) in full-list mode, not just the current category', () => {
|
||||
it('excludes other categories from full-list search', () => {
|
||||
// The current category (itvChannels) has only 'Alpha TV'/'Beta TV', but
|
||||
// the portal's full list contains a News channel in another genre.
|
||||
itvFullListActive.set(true);
|
||||
@@ -748,13 +748,13 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
|
||||
expect(
|
||||
component.filteredChannels().map((channel) => channel.name)
|
||||
).toEqual(['CNN International']);
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes paged censored-category channels in full-list search results', () => {
|
||||
// The adult category's channels come from the legacy paged flow and
|
||||
// are intentionally absent from the full-list cache — searching for a
|
||||
// currently visible channel must still find it (merged source).
|
||||
// currently visible channel must still find it within this category.
|
||||
itvFullListActive.set(true);
|
||||
itvSelectedCategoryFromCache.set(false);
|
||||
itvChannels.set([
|
||||
@@ -774,15 +774,15 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
component.filteredChannels().map((channel) => channel.name)
|
||||
).toEqual(['Erox HD']);
|
||||
|
||||
// And the cached portal-wide channels remain searchable too.
|
||||
// A cache hit in another category must not leak into this category.
|
||||
searchPhrase.set('alpha');
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
component.filteredChannels().map((channel) => channel.name)
|
||||
).toEqual(['Alpha TV']);
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('grows the render window to include a channel selected beyond it (remote/numeric nav)', async () => {
|
||||
it.each([true, false])('grows the render window for remote/numeric selection (cached=%s)', async (cached) => {
|
||||
const full = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `ch-${index}`,
|
||||
cmd: `ffrt4://itv/${index}`,
|
||||
@@ -791,9 +791,10 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
logo: '',
|
||||
}));
|
||||
itvFullListActive.set(true);
|
||||
itvSelectedCategoryFromCache.set(true);
|
||||
itvSelectedCategoryFromCache.set(cached);
|
||||
itvChannels.set(full);
|
||||
itvFullChannelList.set(full);
|
||||
itvFullChannelList.set(cached ? full : []);
|
||||
searchPhrase.set('channel');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.visibleChannels()).toHaveLength(100);
|
||||
|
||||
+99
-86
@@ -19,6 +19,7 @@ import {
|
||||
viewChild,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
@@ -77,8 +78,6 @@ import {
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
createLogger,
|
||||
getAdjacentChannelItem,
|
||||
getChannelItemByNumber,
|
||||
isTypingInInput,
|
||||
LiveEpgPanelState,
|
||||
persistLiveEpgPanelState,
|
||||
@@ -137,6 +136,8 @@ function matchesStalkerChannelTerm(
|
||||
.includes(term);
|
||||
}
|
||||
|
||||
import { StalkerLiveNavigation } from './stalker-live-navigation';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-live-stream-layout',
|
||||
templateUrl: './stalker-live-stream-layout.component.html',
|
||||
@@ -218,36 +219,12 @@ export class StalkerLiveStreamLayoutComponent
|
||||
!this.isRadioMode() &&
|
||||
this.stalkerStore.itvSelectedCategoryFromCache()
|
||||
);
|
||||
/**
|
||||
* The rows a search term is matched against: the current category, or in
|
||||
* full-list mode the WHOLE portal's channel list (every category) merged
|
||||
* with the currently loaded channels, because a censored (adult) category
|
||||
* is paged from the portal and its channels are intentionally absent from
|
||||
* the full-list cache.
|
||||
*/
|
||||
private readonly searchableChannels = computed(() => {
|
||||
const source = this.channels();
|
||||
if (!this.isFullListMode()) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const merged = new Map<string, StalkerItvChannel>();
|
||||
for (const channel of source) {
|
||||
merged.set(normalizeStalkerEntityId(channel.id), channel);
|
||||
}
|
||||
for (const channel of this.stalkerStore.itvFullChannelList()) {
|
||||
const id = normalizeStalkerEntityId(channel.id);
|
||||
if (!merged.has(id)) {
|
||||
merged.set(id, channel);
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
});
|
||||
/**
|
||||
* Channels matching the search phrase. Without a term, the current
|
||||
* category; with one, `searchableChannels` filtered, so search behaves
|
||||
* like "search all channels" whenever the full list is cached.
|
||||
/** The store supplies the complete category when cached, accumulated pages otherwise.
|
||||
* Only All Items has portal-wide scope. Both search fields share this source.
|
||||
*/
|
||||
private readonly searchableChannels = computed(() =>
|
||||
this.showItvAllItems() ? this.itvFullChannelList() : this.channels()
|
||||
);
|
||||
readonly filteredChannels = computed(() => {
|
||||
const term = this.searchTerm();
|
||||
if (!term) {
|
||||
@@ -295,9 +272,11 @@ export class StalkerLiveStreamLayoutComponent
|
||||
computation: () => FULL_LIST_RENDER_CHUNK,
|
||||
});
|
||||
readonly visibleChannels = computed(() =>
|
||||
this.isCategoryFromCache()
|
||||
? this.filteredChannels().slice(0, this.renderLimit())
|
||||
: this.filteredChannels()
|
||||
this.navigation.withRevealedItem(
|
||||
this.isCategoryFromCache() || Boolean(this.searchTerm())
|
||||
? this.filteredChannels().slice(0, this.renderLimit())
|
||||
: this.filteredChannels()
|
||||
)
|
||||
);
|
||||
/**
|
||||
* The fullscreen panel's rows while its own search field is blank: the
|
||||
@@ -327,10 +306,10 @@ export class StalkerLiveStreamLayoutComponent
|
||||
: this.stalkerStore.hasMoreChannels()
|
||||
);
|
||||
readonly totalChannelCount = computed(() => this.filteredChannels().length);
|
||||
readonly hasMoreItems = computed(() =>
|
||||
this.isCategoryFromCache()
|
||||
? this.visibleChannels().length < this.filteredChannels().length
|
||||
: this.stalkerStore.hasMoreChannels()
|
||||
readonly hasMoreItems = computed(
|
||||
() =>
|
||||
this.visibleChannels().length < this.filteredChannels().length ||
|
||||
(!this.isCategoryFromCache() && this.stalkerStore.hasMoreChannels())
|
||||
);
|
||||
readonly isLoadingMore = signal(false);
|
||||
/**
|
||||
@@ -341,7 +320,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
readonly isInitialChannelsLoading = computed(
|
||||
() =>
|
||||
!!this.stalkerStore.selectedCategoryId() &&
|
||||
this.channels().length === 0 &&
|
||||
this.visibleChannels().length === 0 &&
|
||||
!this.searchTerm() &&
|
||||
(this.isFullListLoading() ||
|
||||
this.stalkerStore.isPaginatedContentLoading())
|
||||
@@ -351,7 +330,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
() =>
|
||||
!!this.stalkerStore.selectedCategoryId() &&
|
||||
!this.searchTerm() &&
|
||||
this.channels().length === 0 &&
|
||||
this.visibleChannels().length === 0 &&
|
||||
!this.isInitialChannelsLoading()
|
||||
);
|
||||
|
||||
@@ -595,6 +574,19 @@ export class StalkerLiveStreamLayoutComponent
|
||||
: (this.fullscreenChannelPanelTemplate() ?? null)
|
||||
);
|
||||
readonly panelTitle = computed(() => this.selectedCategoryTitle() ?? '');
|
||||
readonly navigation = new StalkerLiveNavigation({
|
||||
store: this.stalkerStore,
|
||||
router: inject(Router, { optional: true }),
|
||||
sidebar: this.liveSidebarStateService,
|
||||
rows: (term) => this.navigationRows(term),
|
||||
play: (item) => {
|
||||
void this.playChannel(item, true, 'preserve');
|
||||
},
|
||||
revealRow: (id) => this.revealPlayingRow(id),
|
||||
loading: () =>
|
||||
this.isLoadingMore() ||
|
||||
this.stalkerStore.isPaginatedContentLoading(),
|
||||
});
|
||||
private epgPreviewRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
@@ -653,6 +645,8 @@ export class StalkerLiveStreamLayoutComponent
|
||||
effect(() => {
|
||||
const contentType = this.stalkerStore.selectedContentType();
|
||||
this.stalkerStore.selectedCategoryId();
|
||||
this.panelSearch.clear();
|
||||
this.isLoadingMore.set(false);
|
||||
untracked(() => {
|
||||
if (contentType === 'radio') {
|
||||
this.stalkerStore.setRadioChannels([]);
|
||||
@@ -766,9 +760,9 @@ export class StalkerLiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItem = this.stalkerStore.selectedItem();
|
||||
const selectedItem = this.navigation.activeItem();
|
||||
const selectedType = this.stalkerStore.selectedContentType();
|
||||
const channels = this.filteredChannels();
|
||||
const channels = this.navigation.channels();
|
||||
|
||||
// Radio shares this layout and its remote channel handlers, so
|
||||
// it must publish live status too — otherwise the remote shows
|
||||
@@ -835,6 +829,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.navigation.reset();
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
// Leaving the live view would otherwise keep the last channel
|
||||
@@ -869,8 +864,13 @@ export class StalkerLiveStreamLayoutComponent
|
||||
|
||||
async playChannel(
|
||||
item: StalkerItvChannel,
|
||||
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
|
||||
startPlayback = !this.settingsStore.openStreamOnDoubleClick(),
|
||||
navigationContext?: Signal<string> | 'preserve'
|
||||
) {
|
||||
const commitNavigation = this.navigation.prepare(
|
||||
item,
|
||||
navigationContext
|
||||
);
|
||||
const requestId = ++this.playbackRequestId;
|
||||
const channelId = normalizeStalkerEntityId(item.id);
|
||||
const sourceId = normalizeStalkerEntityId(
|
||||
@@ -931,12 +931,14 @@ export class StalkerLiveStreamLayoutComponent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
commitNavigation();
|
||||
this.setActivePlayback(playback, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (deferSelection && (!sourceId || !channelId)) return;
|
||||
commitNavigation();
|
||||
if (deferSelection) {
|
||||
if (!sourceId || !channelId) return;
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
}
|
||||
|
||||
@@ -1047,13 +1049,13 @@ export class StalkerLiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* In full-list mode the rendered list is windowed to `renderLimit`. When a
|
||||
* Cached categories and search results are windowed to `renderLimit`. When a
|
||||
* channel beyond that window is selected (remote channel-up/down, numeric
|
||||
* select), grow the window so the selection is actually in the DOM and can
|
||||
* be highlighted/scrolled to instead of drifting off-window.
|
||||
*/
|
||||
private ensureChannelWithinRenderWindow(channelId: string): void {
|
||||
if (!this.isCategoryFromCache()) {
|
||||
if (this.visibleChannels().length === this.filteredChannels().length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1110,17 +1112,20 @@ export class StalkerLiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (this.isCategoryFromCache()) {
|
||||
// Extends the render window over the in-memory list — no request.
|
||||
if (this.hasMoreItems()) {
|
||||
this.growRenderWindow();
|
||||
}
|
||||
if (this.visibleChannels().length < this.filteredChannels().length) {
|
||||
this.growRenderWindow();
|
||||
return;
|
||||
}
|
||||
if (this.isCategoryFromCache()) return;
|
||||
this.loadNextChannelPage();
|
||||
}
|
||||
|
||||
private loadNextChannelPage(): void {
|
||||
// Legacy portal pagination — also used for censored (adult) genres
|
||||
// that are absent from the full-list cache.
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
if (this.isLoadingMore() || !this.stalkerStore.hasMoreChannels())
|
||||
return;
|
||||
if (this.stalkerStore.isPaginatedContentLoading()) return;
|
||||
this.isLoadingMore.set(true);
|
||||
const nextPage = this.stalkerStore.page() + 1;
|
||||
this.stalkerStore.setPage(nextPage);
|
||||
@@ -1137,7 +1142,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
this.growRenderWindow();
|
||||
return;
|
||||
}
|
||||
this.loadMore();
|
||||
this.loadNextChannelPage();
|
||||
}
|
||||
|
||||
private growRenderWindow(): void {
|
||||
@@ -1556,7 +1561,8 @@ export class StalkerLiveStreamLayoutComponent
|
||||
if (
|
||||
!shouldAutoFillStampedList(
|
||||
isPanelContainer,
|
||||
sidebarSearchActive
|
||||
sidebarSearchActive,
|
||||
!this.isRadioMode()
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
@@ -1571,8 +1577,8 @@ export class StalkerLiveStreamLayoutComponent
|
||||
* panel's copy, while searching with its own term, scrolls through its
|
||||
* own windowed matches, not the sidebar's rows: it grows that window
|
||||
* first and only pages the portal once the window covers every loaded
|
||||
* match — and never in full-list mode, where its search already sees
|
||||
* the whole catalog and a page would only widen the sidebar's window.
|
||||
* match. Cached categories (and All Items) already expose their complete
|
||||
* source and never request provider pages from the panel.
|
||||
*/
|
||||
private driveStampedList(container: HTMLElement, nearEnd: boolean) {
|
||||
// A closed panel stays mounted to preserve search and scroll, but
|
||||
@@ -1592,7 +1598,10 @@ export class StalkerLiveStreamLayoutComponent
|
||||
this.panelSearch.loadMore();
|
||||
return;
|
||||
}
|
||||
if (isPanelSearch && this.isCategoryFromCache()) return;
|
||||
if (isPanelSearch) {
|
||||
if (!this.panelUsesCachedRows()) this.loadNextChannelPage();
|
||||
return;
|
||||
}
|
||||
if (isPanelContainer && !isPanelSearch) {
|
||||
// The blank panel shows the category, not the sidebar's filtered
|
||||
// rows, so its continuation is judged against the category too.
|
||||
@@ -1730,24 +1739,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
private handleAdjacentChannelChange(direction: 'up' | 'down'): void {
|
||||
const activeItem = this.stalkerStore.selectedItem();
|
||||
if (!activeItem?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = this.filteredChannels();
|
||||
const nextItem = getAdjacentChannelItem(
|
||||
channels,
|
||||
activeItem.id,
|
||||
direction,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
if (!nextItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.playChannel(nextItem, true);
|
||||
this.navigation.adjacent(direction);
|
||||
}
|
||||
|
||||
private handleRemoteControlCommand(command: {
|
||||
@@ -1758,19 +1750,40 @@ export class StalkerLiveStreamLayoutComponent
|
||||
| 'volume-toggle-mute';
|
||||
number?: number;
|
||||
}): void {
|
||||
if (command.type !== 'channel-select-number' || !command.number) {
|
||||
return;
|
||||
}
|
||||
if (command.type === 'channel-select-number')
|
||||
this.navigation.selectNumber(command.number);
|
||||
}
|
||||
|
||||
const channel = getChannelItemByNumber(
|
||||
this.filteredChannels(),
|
||||
command.number
|
||||
);
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
private navigationRows(term?: Signal<string>): StalkerItvChannel[] {
|
||||
if (!term)
|
||||
return this.showItvAllItems()
|
||||
? this.itvFullChannelList().filter((item) =>
|
||||
matchesStalkerChannelTerm(item, this.searchTerm())
|
||||
)
|
||||
: this.filteredChannels();
|
||||
const query = term().trim().toLowerCase();
|
||||
return query
|
||||
? this.searchableChannels().filter((item) =>
|
||||
matchesStalkerChannelTerm(item, query)
|
||||
)
|
||||
: this.panelIdleSource();
|
||||
}
|
||||
|
||||
void this.playChannel(channel, true);
|
||||
private revealPlayingRow(id: string): boolean {
|
||||
this.ensureChannelWithinRenderWindow(id);
|
||||
this.cdr.detectChanges();
|
||||
const pane = this.scrollContainers().find(
|
||||
(container) => container.nativeElement.id === 'live-channels'
|
||||
)?.nativeElement as HTMLElement | undefined;
|
||||
const row =
|
||||
pane &&
|
||||
Array.from(
|
||||
pane.querySelectorAll<HTMLElement>('[data-channel-id]')
|
||||
).find((element) => element.dataset['channelId'] === id);
|
||||
if (!row || !pane) return false;
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
pane.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
|
||||
+92
-6
@@ -57,7 +57,7 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
|
||||
selectedItvId: signal<string | undefined>(undefined),
|
||||
selectedItem: signal(null),
|
||||
itvChannels,
|
||||
radioChannels: signal([]),
|
||||
radioChannels: signal<typeof channels>([]),
|
||||
searchPhrase,
|
||||
hasMoreChannels,
|
||||
page,
|
||||
@@ -97,7 +97,10 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
|
||||
itvFullListActive.set(false);
|
||||
itvSelectedCategoryFromCache.set(false);
|
||||
store.selectedCategoryId.set('all');
|
||||
store.selectedContentType.set('itv');
|
||||
store.radioChannels.set([]);
|
||||
store.itvFullChannelList.set([]);
|
||||
store.itvFullListLoading.set(false);
|
||||
store.setPage.mockClear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
@@ -159,6 +162,91 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('searches the complete selected category in both independent fields', () => {
|
||||
const category = Array.from({ length: 250 }, (_, index) => ({
|
||||
...channels[0],
|
||||
id: `selected-${index}`,
|
||||
name: `Shared ${index}`,
|
||||
o_name: `Shared ${index}`,
|
||||
}));
|
||||
const foreign = {
|
||||
...channels[0],
|
||||
id: 'foreign',
|
||||
name: 'Shared foreign',
|
||||
o_name: 'Shared foreign',
|
||||
};
|
||||
itvFullListActive.set(true);
|
||||
itvSelectedCategoryFromCache.set(true);
|
||||
itvChannels.set(category);
|
||||
store.itvFullChannelList.set([...category, foreign]);
|
||||
searchPhrase.set('shared');
|
||||
fixture.detectChanges();
|
||||
expect(component.filteredChannels()).toEqual(category);
|
||||
expect(component.visibleChannels()).toHaveLength(100);
|
||||
const panelTerm = signal('shared 249');
|
||||
expect(component.channelsForList(panelTerm)).toEqual([category[249]]);
|
||||
expect(searchPhrase()).toBe('shared');
|
||||
searchPhrase.set('shared 248');
|
||||
expect(component.filteredChannels()).toEqual([category[248]]);
|
||||
expect(component.channelsForList(panelTerm)).toEqual([category[249]]);
|
||||
|
||||
store.selectedCategoryId.set('next');
|
||||
itvChannels.set([foreign]);
|
||||
fixture.detectChanges();
|
||||
expect(component.filteredChannels()).toEqual([]);
|
||||
expect(component.channelsForList(panelTerm)).toEqual([]);
|
||||
searchPhrase.set('');
|
||||
panelTerm.set('');
|
||||
expect(component.channelsForList()).toEqual([foreign]);
|
||||
expect(component.channelsForList(panelTerm)).toEqual([foreign]);
|
||||
expect(store.resolveItvPlayback).not.toHaveBeenCalled();
|
||||
|
||||
store.selectedCategoryId.set(null);
|
||||
searchPhrase.set('shared');
|
||||
panelTerm.set('shared');
|
||||
fixture.detectChanges();
|
||||
expect(component.filteredChannels()).toHaveLength(251);
|
||||
expect(component.channelsForList(panelTerm)).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('searches paged All Items while the full cache is still loading', () => {
|
||||
store.selectedCategoryId.set('*');
|
||||
store.itvFullListLoading.set(true);
|
||||
searchPhrase.set('one');
|
||||
fixture.detectChanges();
|
||||
expect(component.showItvAllItems()).toBe(false);
|
||||
expect(component.filteredChannels()).toEqual([channels[0]]);
|
||||
expect(component.channelsForList(signal('two'))).toEqual([channels[1]]);
|
||||
});
|
||||
|
||||
it('does not relabel retained category rows as the initial All Items grid', () => {
|
||||
// No selected category has no provider-page request. The unselected
|
||||
// grid waits for the public cache, even if a prior category's rows
|
||||
// are still present before the store's reset effect settles.
|
||||
store.selectedCategoryId.set(null);
|
||||
store.itvFullListLoading.set(true);
|
||||
searchPhrase.set('one');
|
||||
fixture.detectChanges();
|
||||
expect(component.showItvAllItems()).toBe(true);
|
||||
expect(component.filteredChannels()).toEqual([]);
|
||||
expect(component.channelsForList(signal('two'))).toEqual([]);
|
||||
store.itvFullChannelList.set(channels);
|
||||
expect(component.filteredChannels()).toEqual([channels[0]]);
|
||||
expect(component.channelsForList(signal('two'))).toEqual([channels[1]]);
|
||||
});
|
||||
|
||||
it('does not search the ITV cache after switching to radio', () => {
|
||||
itvFullListActive.set(true);
|
||||
store.itvFullChannelList.set(channels);
|
||||
store.selectedContentType.set('radio');
|
||||
store.radioChannels.set([channels[1]]);
|
||||
searchPhrase.set('one');
|
||||
fixture.detectChanges();
|
||||
expect(component.filteredChannels()).toEqual([]);
|
||||
searchPhrase.set('two');
|
||||
expect(component.filteredChannels()).toEqual([channels[1]]);
|
||||
});
|
||||
|
||||
it('keeps requesting pages while an empty panel search cannot scroll', async () => {
|
||||
// A term with no match on the loaded page renders nothing the user
|
||||
// could scroll, yet later pages may hold the channel — so the empty
|
||||
@@ -307,16 +395,14 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
|
||||
expect(container.classList.contains('app-scrollbar')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the sidebar alone while its own search is active', async () => {
|
||||
// Only the panel copy fills itself during a sidebar search; the
|
||||
// sidebar has never paged automatically then, and this fixture
|
||||
// renders the sidebar copy only.
|
||||
it('pages the sidebar when its search cannot fill the viewport', async () => {
|
||||
// A short ITV result cannot scroll but later category pages may match.
|
||||
hasMoreChannels.set(true);
|
||||
searchPhrase.set('one');
|
||||
fixture.detectChanges();
|
||||
await settle();
|
||||
|
||||
expect(store.setPage).not.toHaveBeenCalled();
|
||||
expect(store.setPage).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('pauses automatic paging while the panel is closed and resumes on reopen', async () => {
|
||||
|
||||
+57
-6
@@ -27,12 +27,37 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
const originalElectron = window.electron;
|
||||
|
||||
const itvChannels = signal([
|
||||
{ id: '10001', cmd: 'ffrt4://itv/1', name: 'Alpha TV', o_name: 'Alpha TV', logo: 'a.png' },
|
||||
{ id: '10002', cmd: 'ffrt4://itv/2', name: 'Beta TV', o_name: 'Beta TV', logo: 'b.png' },
|
||||
{
|
||||
id: '10001',
|
||||
cmd: 'ffrt4://itv/1',
|
||||
name: 'Alpha TV',
|
||||
o_name: 'Alpha TV',
|
||||
logo: 'a.png',
|
||||
},
|
||||
{
|
||||
id: '10002',
|
||||
cmd: 'ffrt4://itv/2',
|
||||
name: 'Beta TV',
|
||||
o_name: 'Beta TV',
|
||||
logo: 'b.png',
|
||||
},
|
||||
]);
|
||||
const originalItvChannels = itvChannels();
|
||||
const radioChannels = signal([
|
||||
{ id: 'radio-1', cmd: 'ifm https://s/jazz.mp3', name: 'Jazz FM', o_name: 'Jazz FM', logo: 'j.png' },
|
||||
{ id: 'radio-2', cmd: 'ifm https://s/news.mp3', name: 'News Radio', o_name: 'News Radio', logo: 'n.png' },
|
||||
{
|
||||
id: 'radio-1',
|
||||
cmd: 'ifm https://s/jazz.mp3',
|
||||
name: 'Jazz FM',
|
||||
o_name: 'Jazz FM',
|
||||
logo: 'j.png',
|
||||
},
|
||||
{
|
||||
id: 'radio-2',
|
||||
cmd: 'ifm https://s/news.mp3',
|
||||
name: 'News Radio',
|
||||
o_name: 'News Radio',
|
||||
logo: 'n.png',
|
||||
},
|
||||
]);
|
||||
const selectedItem = signal<{
|
||||
id: string;
|
||||
@@ -93,8 +118,35 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
|
||||
const updateRemoteControlStatus = jest.fn();
|
||||
|
||||
it('keeps remote order and number when browsing excludes the playing channel', async () => {
|
||||
const original = itvChannels();
|
||||
stalkerStore.setSelectedItem.mockImplementation((item) =>
|
||||
selectedItem.set(item)
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.componentInstance.playChannel(original[0]);
|
||||
fixture.detectChanges();
|
||||
stalkerStore.selectedCategoryId.set('sports');
|
||||
itvChannels.set([{ ...original[0], id: '90001', name: 'Sports TV' }]);
|
||||
fixture.detectChanges();
|
||||
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ channelNumber: 1 })
|
||||
);
|
||||
const host = fixture.componentInstance as unknown as {
|
||||
handleRemoteChannelChange(direction: 'down'): void;
|
||||
};
|
||||
host.handleRemoteChannelChange('down');
|
||||
await fixture.whenStable();
|
||||
expect(selectedItem()?.id).toBe(original[1].id);
|
||||
itvChannels.set(original);
|
||||
stalkerStore.selectedCategoryId.set('1001');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
updateRemoteControlStatus.mockClear();
|
||||
itvChannels.set(originalItvChannels);
|
||||
stalkerStore.selectedCategoryId.set('1001');
|
||||
stalkerStore.setSelectedItem.mockReset();
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
@@ -128,8 +180,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
// remote-control bridge method must be present.
|
||||
get supportsRemoteControl() {
|
||||
const bridge = window.electron as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
return [
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { computed, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
convertToParamMap,
|
||||
NavigationEnd,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
FavoritesService,
|
||||
XtreamStore,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
RecordingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { BehaviorSubject, of, Subject } from 'rxjs';
|
||||
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
|
||||
import { XtreamLiveChannelItem } from './xtream-live-channel-navigation.service';
|
||||
|
||||
const target = { xtream_id: 202, category_id: '7', name: 'Search Channel' };
|
||||
const alpha = { xtream_id: 201, category_id: '7', name: 'Alpha' };
|
||||
const zulu = { xtream_id: 203, category_id: '7', name: 'Zulu' };
|
||||
const other = { xtream_id: 101, category_id: '1', name: 'Other' };
|
||||
const catalog = [other, target, zulu, alpha];
|
||||
|
||||
describe('Xtream live auto-open playback queue', () => {
|
||||
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
|
||||
let component: LiveStreamLayoutComponent;
|
||||
const selectedCategoryId = signal<number | null>(1);
|
||||
const selectedItem = signal<XtreamLiveChannelItem | null>(null);
|
||||
const categorySearchTerm = signal('');
|
||||
const visibleItems = computed(() =>
|
||||
catalog.filter(
|
||||
(item) =>
|
||||
(!selectedCategoryId() ||
|
||||
Number(item.category_id) === selectedCategoryId()) &&
|
||||
item.name
|
||||
.toLowerCase()
|
||||
.includes(categorySearchTerm().toLowerCase())
|
||||
)
|
||||
);
|
||||
let query: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let events: Subject<NavigationEnd>;
|
||||
const store = {
|
||||
getCategoriesBySelectedType: signal([
|
||||
{ category_id: 1 },
|
||||
{ category_id: 7 },
|
||||
]),
|
||||
getCategoryItemCounts: signal(new Map()),
|
||||
epgItems: signal([]),
|
||||
currentEpgItem: signal(null),
|
||||
selectedTypeContentLoading: signal(false),
|
||||
isLoadingEpg: signal(false),
|
||||
selectedCategoryId,
|
||||
selectedItem,
|
||||
categorySearchTerm,
|
||||
selectedContentType: signal('live'),
|
||||
currentPlaylist: signal({ id: 'playlist-1' }),
|
||||
liveStreams: signal(catalog),
|
||||
getPaginatedContent: signal(catalog),
|
||||
hasMoreContent: signal(false),
|
||||
selectItemsFromSelectedCategory: visibleItems,
|
||||
setSelectedCategory: jest.fn((id: number) =>
|
||||
selectedCategoryId.set(id)
|
||||
),
|
||||
setSelectedItem: jest.fn((item: XtreamLiveChannelItem) =>
|
||||
selectedItem.set(item)
|
||||
),
|
||||
constructStreamUrl: jest.fn((item: XtreamLiveChannelItem) => {
|
||||
selectedItem.set(item);
|
||||
return `https://example.test/live/${item.xtream_id}.ts`;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedCategoryId.set(1);
|
||||
selectedItem.set(null);
|
||||
categorySearchTerm.set('');
|
||||
store.constructStreamUrl.mockClear();
|
||||
store.setSelectedCategory.mockClear();
|
||||
window.history.replaceState({}, '');
|
||||
localStorage.removeItem('xtream-live-channel-sort-mode');
|
||||
query = new BehaviorSubject(convertToParamMap({}));
|
||||
events = new Subject();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LiveStreamLayoutComponent],
|
||||
providers: [
|
||||
{ provide: XtreamStore, useValue: store },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
data: {},
|
||||
params: {},
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
queryParamMap: query,
|
||||
pathFromRoot: [
|
||||
{ snapshot: { data: { layout: 'workspace' } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ provide: Router, useValue: { events, navigate: jest.fn() } },
|
||||
{
|
||||
provide: FavoritesService,
|
||||
useValue: { getFavorites: () => of([]) },
|
||||
},
|
||||
{ provide: XtreamUrlService, useValue: {} },
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: { isEmbeddedPlayer: () => true },
|
||||
},
|
||||
{ provide: RecordingsService, useValue: {} },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
isElectron: false,
|
||||
supportsEpg: false,
|
||||
supportsRemoteControl: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
resolvedEpgViewMode: signal('timeline'),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(LiveStreamLayoutComponent, {
|
||||
set: { template: '', imports: [] },
|
||||
})
|
||||
.compileComponents();
|
||||
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.setLiveChannelSortMode('name-asc');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
window.history.replaceState({}, '');
|
||||
localStorage.removeItem('xtream-live-channel-sort-mode');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ category: 1, query: 'Other' },
|
||||
{ category: null, query: '' },
|
||||
])(
|
||||
'auto-opens with the destination category queue from $category',
|
||||
({ category, query: previousQuery }) => {
|
||||
selectedCategoryId.set(category);
|
||||
categorySearchTerm.set(previousQuery);
|
||||
query.next(convertToParamMap({ q: previousQuery }));
|
||||
window.history.replaceState(
|
||||
{ openXtreamLiveItemId: target.xtream_id },
|
||||
''
|
||||
);
|
||||
events.next(new NavigationEnd(1, '/live', '/live'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(store.setSelectedCategory).toHaveBeenCalledWith(7);
|
||||
expect(component.channelNavigation.remoteChannels()).toEqual([
|
||||
alpha,
|
||||
target,
|
||||
zulu,
|
||||
]);
|
||||
component['handleRemoteChannelChange']('down');
|
||||
expect(store.constructStreamUrl).toHaveBeenLastCalledWith(zulu);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the actual displayed cross-category list for an explicit All Items click', () => {
|
||||
selectedCategoryId.set(null);
|
||||
component.onLiveRootItemClick(target);
|
||||
expect(component.channelNavigation.remoteChannels()).toEqual(catalog);
|
||||
});
|
||||
});
|
||||
+21
-2
@@ -22,6 +22,19 @@
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
@if (channelNavigation.canReveal()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
'CHANNELS.SHOW_PLAYING_CHANNEL' | translate
|
||||
"
|
||||
[matTooltip]="'CHANNELS.SHOW_PLAYING_CHANNEL' | translate"
|
||||
(click)="channelNavigation.reveal()"
|
||||
>
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="channelSortMenu"
|
||||
@@ -71,6 +84,7 @@
|
||||
</mat-menu>
|
||||
</div>
|
||||
<app-portal-channels-list
|
||||
[revealRequest]="channelNavigation.revealRequest()"
|
||||
[sortMode]="liveChannelSortMode()"
|
||||
[searchTermInput]="workspaceSearchTerm()"
|
||||
(playClicked)="playLive($event)"
|
||||
@@ -217,11 +231,16 @@
|
||||
<!-- Stamped into the player's fullscreen channel panel (FULLSCREEN_CHANNEL_PANEL). -->
|
||||
<ng-template #fullscreenChannelPanel let-searchTerm="searchTerm">
|
||||
<app-portal-channels-list
|
||||
#fullscreenList
|
||||
[sortMode]="liveChannelSortMode()"
|
||||
[channelsOverride]="fullscreenPanelChannels()"
|
||||
[searchTermInput]="searchTerm()"
|
||||
[fullscreenPanelCopy]="true"
|
||||
(playClicked)="playLive($event)"
|
||||
(playbackRequested)="playLive($event, true)"
|
||||
(playClicked)="
|
||||
playLive($event, undefined, fullscreenList.filteredChannels())
|
||||
"
|
||||
(playbackRequested)="
|
||||
playLive($event, true, fullscreenList.filteredChannels())
|
||||
"
|
||||
/>
|
||||
</ng-template>
|
||||
+26
-3
@@ -60,6 +60,8 @@ class StubPortalChannelsListComponent {
|
||||
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
|
||||
readonly channelsOverride = input<unknown[] | null>(null);
|
||||
readonly searchTermInput = input('');
|
||||
readonly revealRequest = input<unknown>(null);
|
||||
readonly filteredChannels = signal<unknown[]>([]);
|
||||
readonly playClicked = output<unknown>();
|
||||
readonly playbackRequested = output<unknown>();
|
||||
}
|
||||
@@ -559,9 +561,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
fixture.nativeElement.querySelector('.category-subtitle')
|
||||
.textContent
|
||||
).toContain('3 channels');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('mat-paginator')
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('mat-paginator')).toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubPortalChannelsListComponent)
|
||||
@@ -1041,9 +1041,29 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
expect(epgTimeline.componentInstance.activeProgram()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps remote order after browsing another category and changing sort', () => {
|
||||
const first = { ...sampleChannel, category_id: '1', name: 'Zulu' };
|
||||
const next = { ...first, xtream_id: 102, name: 'Alpha' };
|
||||
liveStreams.set([first, next]);
|
||||
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([
|
||||
first,
|
||||
next,
|
||||
]);
|
||||
selectedItem.set(first);
|
||||
component.playLive(first);
|
||||
xtreamStore.setSelectedCategory.mockClear();
|
||||
selectedCategoryId.set(2);
|
||||
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([]);
|
||||
component.setLiveChannelSortMode('name-desc');
|
||||
component['handleRemoteChannelChange']('down');
|
||||
expect(xtreamStore.constructStreamUrl).toHaveBeenLastCalledWith(next);
|
||||
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts external playback from remote channel navigation when double-click opening is enabled', () => {
|
||||
const nextChannel = {
|
||||
...sampleChannel,
|
||||
category_id: '1',
|
||||
xtream_id: 102,
|
||||
name: 'Channel 102',
|
||||
};
|
||||
@@ -1055,6 +1075,9 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
nextChannel,
|
||||
]);
|
||||
|
||||
liveStreams.set([{ ...sampleChannel, category_id: '1' }, nextChannel]);
|
||||
component.playLive(sampleChannel);
|
||||
xtreamStore.openPlayer.mockClear();
|
||||
(
|
||||
component as unknown as {
|
||||
handleRemoteChannelChange(direction: 'up' | 'down'): void;
|
||||
|
||||
+29
-25
@@ -95,20 +95,12 @@ import {
|
||||
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
import {
|
||||
XtreamLiveChannelNavigationService,
|
||||
type XtreamLiveChannelItem,
|
||||
} from './xtream-live-channel-navigation.service';
|
||||
|
||||
interface XtreamLiveChannelItem {
|
||||
readonly added?: string;
|
||||
readonly category_id?: string | number;
|
||||
readonly last_modified?: string;
|
||||
readonly name?: string;
|
||||
readonly poster_url?: string;
|
||||
readonly stream_icon?: string;
|
||||
readonly title?: string;
|
||||
readonly tv_archive?: number | null;
|
||||
readonly tv_archive_duration?: number | string | null;
|
||||
readonly xtream_id: number;
|
||||
}
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-live-stream-layout',
|
||||
@@ -116,6 +108,7 @@ interface XtreamLiveChannelItem {
|
||||
styleUrls: ['./live-stream-layout.component.scss'],
|
||||
providers: [
|
||||
LiveStreamAutoOpenStateService,
|
||||
XtreamLiveChannelNavigationService,
|
||||
// The fullscreen channel panel inside the player renders this
|
||||
// category's channel list (see the `fullscreenChannelPanel` template).
|
||||
{
|
||||
@@ -169,12 +162,12 @@ export class LiveStreamLayoutComponent
|
||||
this.xtreamStore.selectedTypeContentLoading;
|
||||
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
|
||||
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
|
||||
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
|
||||
readonly channelNavigation = inject(XtreamLiveChannelNavigationService);
|
||||
readonly liveChannelSortMode = this.channelNavigation.sortMode;
|
||||
|
||||
private readonly fullscreenChannelPanelTemplate =
|
||||
viewChild<TemplateRef<FullscreenChannelPanelContext>>(
|
||||
'fullscreenChannelPanel'
|
||||
);
|
||||
private readonly fullscreenChannelPanelTemplate = viewChild<
|
||||
TemplateRef<FullscreenChannelPanelContext>
|
||||
>('fullscreenChannelPanel');
|
||||
/** FULLSCREEN_CHANNEL_PANEL: the current category's list, unless opted out. */
|
||||
readonly panelTemplate = computed(() =>
|
||||
this.settingsStore.fullscreenChannelPanel?.() === false
|
||||
@@ -447,7 +440,11 @@ export class LiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
this.playLive(item);
|
||||
this.playLive(
|
||||
item,
|
||||
undefined,
|
||||
this.channelNavigation.channelsForAutoOpen(item)
|
||||
);
|
||||
// Ensure selectedItem is set so EPG loading and remote-control
|
||||
// status reflect the channel (constructStreamUrl also does this
|
||||
// internally, but an explicit call makes the intent clear and
|
||||
@@ -540,15 +537,18 @@ export class LiveStreamLayoutComponent
|
||||
|
||||
playLive(
|
||||
item: XtreamLiveChannelItem,
|
||||
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
|
||||
startPlayback = !this.settingsStore.openStreamOnDoubleClick(),
|
||||
displayedChannels?: readonly XtreamLiveChannelItem[],
|
||||
remote = false
|
||||
) {
|
||||
this.playbackRequestId += 1;
|
||||
const streamUrl = this.xtreamStore.constructStreamUrl(item);
|
||||
this.channelNavigation.capture(item, displayedChannels, remote);
|
||||
this.activeCatchupProgram.set(null);
|
||||
// Keep both root/recently-added playback and same-category replays in
|
||||
// sync with the category rail. For already-selected channels this is a
|
||||
// store no-op.
|
||||
this.selectLiveItemCategory(item);
|
||||
if (!remote) this.selectLiveItemCategory(item);
|
||||
this.activeLiveItemId.set(item.xtream_id);
|
||||
this.activePlayback.set({
|
||||
streamUrl,
|
||||
@@ -590,7 +590,11 @@ export class LiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
onLiveRootItemClick(item: unknown): void {
|
||||
this.playLive(item as XtreamLiveChannelItem);
|
||||
this.playLive(
|
||||
item as XtreamLiveChannelItem,
|
||||
undefined,
|
||||
this.liveRootItems() as unknown as XtreamLiveChannelItem[]
|
||||
);
|
||||
}
|
||||
|
||||
onLiveEpgPanelCollapsedChange(collapsed: boolean): void {
|
||||
@@ -667,7 +671,7 @@ export class LiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
this.playLive(nextItem, true);
|
||||
this.playLive(nextItem, true, undefined, true);
|
||||
}
|
||||
|
||||
private handleRemoteControlCommand(command: {
|
||||
@@ -688,7 +692,7 @@ export class LiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
this.playLive(channel, true);
|
||||
this.playLive(channel, true, undefined, true);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
@@ -705,7 +709,7 @@ export class LiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
private getVisibleChannels(): XtreamLiveChannelItem[] {
|
||||
return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[];
|
||||
return [...this.channelNavigation.remoteChannels()];
|
||||
}
|
||||
|
||||
private selectLiveItemCategory(item: XtreamLiveChannelItem): void {
|
||||
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
convertToParamMap,
|
||||
NavigationStart,
|
||||
Router,
|
||||
provideRouter,
|
||||
} from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { XtreamLiveChannelNavigationService } from './xtream-live-channel-navigation.service';
|
||||
|
||||
const first = { xtream_id: 1, category_id: '10', title: 'Zulu' };
|
||||
const second = { xtream_id: 2, category_id: '10', title: 'Alpha' };
|
||||
const other = { xtream_id: 3, category_id: '20', title: 'Other' };
|
||||
|
||||
@Component({
|
||||
template: '',
|
||||
providers: [XtreamLiveChannelNavigationService],
|
||||
})
|
||||
class NavigationHostComponent {
|
||||
readonly navigation = inject(XtreamLiveChannelNavigationService);
|
||||
}
|
||||
|
||||
describe('XtreamLiveChannelNavigationService', () => {
|
||||
const currentPlaylist = signal({ id: 'source-1' });
|
||||
const selectedContentType = signal('live');
|
||||
const selectedCategoryId = signal<number | null>(10);
|
||||
const categorySearchTerm = signal('');
|
||||
const liveStreams = signal([first, second, other]);
|
||||
const categories = signal([
|
||||
{ category_id: 10, hidden: false },
|
||||
{ category_id: 20, hidden: false },
|
||||
]);
|
||||
const selectedChannels = computed(() =>
|
||||
liveStreams().filter(
|
||||
(item) =>
|
||||
(!selectedCategoryId() ||
|
||||
Number(item.category_id) === selectedCategoryId()) &&
|
||||
item.title
|
||||
.toLowerCase()
|
||||
.includes(categorySearchTerm().toLowerCase())
|
||||
)
|
||||
);
|
||||
const store = {
|
||||
currentPlaylist,
|
||||
selectedContentType,
|
||||
selectedCategoryId,
|
||||
categorySearchTerm,
|
||||
liveStreams,
|
||||
getCategoriesBySelectedType: categories,
|
||||
selectItemsFromSelectedCategory: selectedChannels,
|
||||
setSelectedCategory: jest.fn((id: number) =>
|
||||
selectedCategoryId.set(id)
|
||||
),
|
||||
setCategorySearchTerm: jest.fn((term: string) =>
|
||||
categorySearchTerm.set(term)
|
||||
),
|
||||
constructStreamUrl: jest.fn(),
|
||||
openPlayer: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
};
|
||||
let service: XtreamLiveChannelNavigationService;
|
||||
let query: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let events: Subject<NavigationStart>;
|
||||
let route: {
|
||||
snapshot: {
|
||||
params: Record<string, string>;
|
||||
queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
};
|
||||
queryParamMap: unknown;
|
||||
};
|
||||
const navigate = jest.fn();
|
||||
const sidebar = { setState: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
currentPlaylist.set({ id: 'source-1' });
|
||||
selectedContentType.set('live');
|
||||
selectedCategoryId.set(10);
|
||||
categorySearchTerm.set('');
|
||||
liveStreams.set([first, second, other]);
|
||||
categories.set([
|
||||
{ category_id: 10, hidden: false },
|
||||
{ category_id: 20, hidden: false },
|
||||
]);
|
||||
query = new BehaviorSubject(convertToParamMap({}));
|
||||
events = new Subject();
|
||||
route = {
|
||||
snapshot: { params: {}, queryParamMap: convertToParamMap({}) },
|
||||
queryParamMap: query.asObservable(),
|
||||
};
|
||||
jest.clearAllMocks();
|
||||
navigate.mockImplementation(() => {
|
||||
events.next(new NavigationStart(1, '/live'));
|
||||
query.next(convertToParamMap({}));
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
XtreamLiveChannelNavigationService,
|
||||
{ provide: XtreamStore, useValue: store },
|
||||
{ provide: ActivatedRoute, useValue: route },
|
||||
{ provide: Router, useValue: { events, navigate } },
|
||||
{ provide: LiveLayoutSidebarStateService, useValue: sidebar },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(XtreamLiveChannelNavigationService);
|
||||
TestBed.flushEffects();
|
||||
});
|
||||
|
||||
it('captures the displayed sort and keeps it after category, search, and sort changes', () => {
|
||||
service.sortMode.set('name-asc');
|
||||
service.capture(first);
|
||||
selectedCategoryId.set(20);
|
||||
service.sortMode.set('name-desc');
|
||||
query.next(convertToParamMap({ q: 'Other' }));
|
||||
expect(service.remoteChannels()).toEqual([second, first]);
|
||||
service.capture(second, service.displayedChannels(), true);
|
||||
expect(service.remoteChannels()).toEqual([second, first]);
|
||||
service.capture(second);
|
||||
expect(service.remoteChannels()).toEqual([second, first]);
|
||||
});
|
||||
|
||||
it('captures fullscreen filtered rows and replaces the queue on a new explicit selection', () => {
|
||||
service.capture(first, [first]);
|
||||
expect(service.remoteChannels()).toEqual([first]);
|
||||
service.capture(other, [other, second]);
|
||||
expect(service.remoteChannels()).toEqual([other, second]);
|
||||
});
|
||||
|
||||
it('drops hidden and removed channels without adopting the browsed list', () => {
|
||||
selectedCategoryId.set(null);
|
||||
service.capture(first);
|
||||
categories.set([
|
||||
{ category_id: 10, hidden: false },
|
||||
{ category_id: 20, hidden: true },
|
||||
]);
|
||||
expect(service.remoteChannels()).toEqual([first, second]);
|
||||
liveStreams.set([second, other]);
|
||||
expect(service.remoteChannels()).toEqual([second]);
|
||||
expect(service.canReveal()).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates a queue when its source or content type changes', () => {
|
||||
service.capture(first);
|
||||
currentPlaylist.set({ id: 'source-2' });
|
||||
expect(service.remoteChannels()).toEqual([]);
|
||||
TestBed.flushEffects();
|
||||
currentPlaylist.set({ id: 'source-1' });
|
||||
expect(service.remoteChannels()).toEqual([]);
|
||||
service.capture(first);
|
||||
selectedContentType.set('vod');
|
||||
TestBed.flushEffects();
|
||||
selectedContentType.set('live');
|
||||
expect(service.remoteChannels()).toEqual([]);
|
||||
expect(service.activeItem()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows reveal only when the playing channel is absent from the actual filtered list', () => {
|
||||
service.capture(first);
|
||||
expect(service.canReveal()).toBe(false);
|
||||
query.next(convertToParamMap({ q: 'Alpha' }));
|
||||
expect(service.canReveal()).toBe(true);
|
||||
categories.set([{ category_id: 10, hidden: true }]);
|
||||
expect(service.canReveal()).toBe(false);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'reveals from a category route=%s without touching playback or queue',
|
||||
async (categoryRoute) => {
|
||||
service.capture(first);
|
||||
selectedCategoryId.set(20);
|
||||
categorySearchTerm.set('Other');
|
||||
query.next(convertToParamMap({ q: 'Other' }));
|
||||
if (categoryRoute) route.snapshot.params = { categoryId: '20' };
|
||||
await service.reveal();
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
categoryRoute ? ['../', 10] : [],
|
||||
{
|
||||
relativeTo: route,
|
||||
queryParams: { q: null },
|
||||
queryParamsHandling: 'merge',
|
||||
}
|
||||
);
|
||||
expect(selectedCategoryId()).toBe(10);
|
||||
expect(categorySearchTerm()).toBe('');
|
||||
expect(sidebar.setState).toHaveBeenCalledWith('expanded');
|
||||
expect(service.revealRequest()).toEqual(
|
||||
expect.objectContaining({ channelId: 1 })
|
||||
);
|
||||
expect(service.remoteChannels()).toEqual([first, second]);
|
||||
expect(store.constructStreamUrl).not.toHaveBeenCalled();
|
||||
expect(store.openPlayer).not.toHaveBeenCalled();
|
||||
expect(store.setSelectedItem).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['live', 'live/20', 'live-without-query'])(
|
||||
'keeps the live host mounted and synchronizes real %s navigation',
|
||||
async (path) => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([
|
||||
{
|
||||
path: 'workspace/xtreams/:id',
|
||||
children: [
|
||||
{
|
||||
path: 'live',
|
||||
component: NavigationHostComponent,
|
||||
},
|
||||
{
|
||||
path: 'live/:categoryId',
|
||||
component: NavigationHostComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
{ provide: XtreamStore, useValue: store },
|
||||
{
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: sidebar,
|
||||
},
|
||||
],
|
||||
});
|
||||
selectedCategoryId.set(20);
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const host = await harness.navigateByUrl(
|
||||
`/workspace/xtreams/source-1/${path === 'live-without-query' ? 'live' : `${path}?q=Other`}`,
|
||||
NavigationHostComponent
|
||||
);
|
||||
host.navigation.capture(first, [first, second]);
|
||||
await host.navigation.reveal();
|
||||
harness.detectChanges();
|
||||
expect(TestBed.inject(Router).url).toBe(
|
||||
`/workspace/xtreams/source-1/${path !== 'live/20' ? 'live' : 'live/10'}`
|
||||
);
|
||||
expect(harness.routeDebugElement?.componentInstance).toBe(host);
|
||||
expect(selectedCategoryId()).toBe(10);
|
||||
expect(host.navigation.revealRequest()?.channelId).toBe(
|
||||
first.xtream_id
|
||||
);
|
||||
expect(host.navigation.remoteChannels()).toEqual([first, second]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
'playback',
|
||||
'source',
|
||||
'category',
|
||||
'navigation',
|
||||
'hidden',
|
||||
'cancelled',
|
||||
])('ignores a stale reveal after %s changes', async (change) => {
|
||||
route.snapshot.params = { categoryId: '20' };
|
||||
service.capture(first);
|
||||
selectedCategoryId.set(20);
|
||||
let finish!: (value: boolean) => void;
|
||||
navigate.mockImplementation(() => {
|
||||
events.next(new NavigationStart(1, '/live'));
|
||||
return new Promise<boolean>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
});
|
||||
const reveal = service.reveal();
|
||||
if (change === 'playback') service.capture(other);
|
||||
if (change === 'source') currentPlaylist.set({ id: 'source-2' });
|
||||
if (change === 'category') selectedCategoryId.set(30);
|
||||
if (change === 'navigation')
|
||||
events.next(new NavigationStart(2, '/elsewhere'));
|
||||
if (change === 'hidden')
|
||||
categories.set([{ category_id: 10, hidden: true }]);
|
||||
finish(change !== 'cancelled');
|
||||
await reveal;
|
||||
expect(store.setSelectedCategory).not.toHaveBeenCalled();
|
||||
expect(sidebar.setState).not.toHaveBeenCalled();
|
||||
expect(service.revealRequest()).toBeNull();
|
||||
});
|
||||
});
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
|
||||
import { LiveChannelPlaybackQueue } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
LiveLayoutSidebarStateService,
|
||||
PortalChannelSortMode,
|
||||
queryParamSignal,
|
||||
sortPortalChannelItems,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
|
||||
export interface XtreamLiveChannelItem {
|
||||
readonly added?: string;
|
||||
readonly category_id?: string | number;
|
||||
readonly last_modified?: string;
|
||||
readonly name?: string;
|
||||
readonly poster_url?: string;
|
||||
readonly stream_icon?: string;
|
||||
readonly title?: string;
|
||||
readonly tv_archive?: number | null;
|
||||
readonly tv_archive_duration?: number | string | null;
|
||||
readonly xtream_id: number;
|
||||
}
|
||||
|
||||
/** Browse state is independent from the ordered list that started playback. */
|
||||
@Injectable()
|
||||
export class XtreamLiveChannelNavigationService {
|
||||
private readonly store = inject(XtreamStore);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly sidebar = inject(LiveLayoutSidebarStateService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly queue =
|
||||
new LiveChannelPlaybackQueue<XtreamLiveChannelItem>(
|
||||
(item) => item.xtream_id
|
||||
);
|
||||
private readonly playing = signal<{
|
||||
owner: string;
|
||||
item: XtreamLiveChannelItem;
|
||||
} | null>(null);
|
||||
readonly sortMode = signal<PortalChannelSortMode>('server');
|
||||
private readonly query = queryParamSignal(this.route, 'q', (q) => q ?? '');
|
||||
private readonly owner = computed(() =>
|
||||
this.store.selectedContentType() === 'live'
|
||||
? `${this.store.currentPlaylist()?.id ?? ''}:live`
|
||||
: ''
|
||||
);
|
||||
readonly activeItem = computed(() => {
|
||||
const playing = this.playing();
|
||||
return playing?.owner === this.owner() ? playing.item : null;
|
||||
});
|
||||
readonly displayedChannels = computed(() => {
|
||||
const term = this.query().trim().toLowerCase();
|
||||
const channels = sortPortalChannelItems(
|
||||
this.store.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[],
|
||||
this.sortMode(),
|
||||
(item) => item.title ?? item.name
|
||||
);
|
||||
return term
|
||||
? channels.filter((item) =>
|
||||
`${item.title ?? ''} ${item.name ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
)
|
||||
: channels;
|
||||
});
|
||||
private readonly accessibleChannels = computed(() => {
|
||||
const categories = new Set(
|
||||
this.store
|
||||
.getCategoriesBySelectedType()
|
||||
.filter(
|
||||
(category) => !('hidden' in category && category.hidden)
|
||||
)
|
||||
.map((category) => Number(category.category_id ?? category.id))
|
||||
);
|
||||
return (this.store.liveStreams() as XtreamLiveChannelItem[]).filter(
|
||||
(item) => categories.has(Number(item.category_id))
|
||||
);
|
||||
});
|
||||
readonly remoteChannels = computed(() => {
|
||||
const available = new Map(
|
||||
this.accessibleChannels().map((item) => [
|
||||
Number(item.xtream_id),
|
||||
item,
|
||||
])
|
||||
);
|
||||
return this.queue
|
||||
.items(this.owner())
|
||||
.map((item) => available.get(Number(item.xtream_id)))
|
||||
.filter(
|
||||
(item): item is XtreamLiveChannelItem => item !== undefined
|
||||
);
|
||||
});
|
||||
readonly canReveal = computed(() => {
|
||||
const active = this.activeItem();
|
||||
return (
|
||||
!!active &&
|
||||
this.accessibleChannels().some(
|
||||
(item) => Number(item.xtream_id) === Number(active.xtream_id)
|
||||
) &&
|
||||
!this.displayedChannels().some(
|
||||
(item) => Number(item.xtream_id) === Number(active.xtream_id)
|
||||
)
|
||||
);
|
||||
});
|
||||
private readonly pendingReveal = signal<{
|
||||
channelId: number;
|
||||
categoryId: number;
|
||||
sequence: number;
|
||||
} | null>(null);
|
||||
readonly revealRequest = computed(() => {
|
||||
const request = this.pendingReveal();
|
||||
return request &&
|
||||
request.channelId === this.activeItem()?.xtream_id &&
|
||||
request.categoryId === this.store.selectedCategoryId() &&
|
||||
!this.query().trim()
|
||||
? request
|
||||
: null;
|
||||
});
|
||||
private requestSequence = 0;
|
||||
private navigationGeneration = 0;
|
||||
|
||||
constructor() {
|
||||
let previousOwner = this.owner();
|
||||
effect(() => {
|
||||
const owner = this.owner();
|
||||
if (owner !== previousOwner) {
|
||||
previousOwner = owner;
|
||||
untracked(() => {
|
||||
this.queue.clear();
|
||||
this.playing.set(null);
|
||||
this.pendingReveal.set(null);
|
||||
this.requestSequence++;
|
||||
});
|
||||
}
|
||||
});
|
||||
this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
this.navigationGeneration++;
|
||||
this.pendingReveal.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** History handoffs carry an item, not a browsed list or its old filter. */
|
||||
channelsForAutoOpen(
|
||||
item: XtreamLiveChannelItem
|
||||
): readonly XtreamLiveChannelItem[] {
|
||||
return sortPortalChannelItems(
|
||||
this.accessibleChannels().filter(
|
||||
(channel) =>
|
||||
Number(channel.category_id) === Number(item.category_id)
|
||||
),
|
||||
this.sortMode(),
|
||||
(channel) => channel.title ?? channel.name
|
||||
);
|
||||
}
|
||||
|
||||
capture(
|
||||
item: XtreamLiveChannelItem,
|
||||
items: readonly XtreamLiveChannelItem[] = this.displayedChannels(),
|
||||
preserveQueue = false
|
||||
): void {
|
||||
this.requestSequence++;
|
||||
this.pendingReveal.set(null);
|
||||
const owner = this.owner();
|
||||
if (!preserveQueue && this.activeItem()?.xtream_id !== item.xtream_id) {
|
||||
this.queue.capture(
|
||||
owner,
|
||||
JSON.stringify([
|
||||
this.store.selectedCategoryId(),
|
||||
this.query(),
|
||||
this.sortMode(),
|
||||
]),
|
||||
items,
|
||||
item
|
||||
);
|
||||
}
|
||||
this.playing.set({ owner, item });
|
||||
}
|
||||
|
||||
async reveal(): Promise<void> {
|
||||
if (!this.canReveal()) return;
|
||||
const active = this.activeItem();
|
||||
const target = this.accessibleChannels().find(
|
||||
(item) => item.xtream_id === active?.xtream_id
|
||||
);
|
||||
if (!target) return;
|
||||
const categoryId = Number(target.category_id);
|
||||
const owner = this.owner();
|
||||
const sequence = ++this.requestSequence;
|
||||
const previousCategory = this.store.selectedCategoryId();
|
||||
const generation = this.navigationGeneration;
|
||||
const routeCategoryId = this.route.snapshot.params?.['categoryId'];
|
||||
const needsNavigation =
|
||||
this.query() ||
|
||||
this.route.snapshot.queryParamMap.has('q') ||
|
||||
(routeCategoryId && Number(routeCategoryId) !== categoryId);
|
||||
// Root category browsing is store-only: Angular reports a same-URL
|
||||
// navigation as false, even though there is nothing to cancel.
|
||||
const navigation = needsNavigation
|
||||
? this.router.navigate(routeCategoryId ? ['../', categoryId] : [], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { q: null },
|
||||
queryParamsHandling: 'merge',
|
||||
})
|
||||
: Promise.resolve(true);
|
||||
try {
|
||||
const navigated = await navigation;
|
||||
if (
|
||||
navigated === false ||
|
||||
this.destroyRef.destroyed ||
|
||||
sequence !== this.requestSequence ||
|
||||
owner !== this.owner() ||
|
||||
this.navigationGeneration > generation + 1 ||
|
||||
this.activeItem()?.xtream_id !== target.xtream_id ||
|
||||
!this.accessibleChannels().some(
|
||||
(item) => item.xtream_id === target.xtream_id
|
||||
) ||
|
||||
(this.store.selectedCategoryId() !== previousCategory &&
|
||||
this.store.selectedCategoryId() !== categoryId)
|
||||
)
|
||||
return;
|
||||
this.store.setCategorySearchTerm('');
|
||||
this.store.setSelectedCategory(categoryId);
|
||||
this.sidebar.setState('expanded');
|
||||
this.pendingReveal.set({
|
||||
channelId: target.xtream_id,
|
||||
categoryId,
|
||||
sequence,
|
||||
});
|
||||
} catch {
|
||||
// Cancelled/failed navigation leaves playback and browse state intact.
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -421,6 +421,41 @@ describe('PortalChannelsListComponent', () => {
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(15, 'smooth');
|
||||
});
|
||||
|
||||
it('explicitly reveals and focuses an unchanged selected channel after browsing away', () => {
|
||||
const requestFrame = jest
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockReturnValue(100000);
|
||||
selectedTypeContentLoading.set(false);
|
||||
selectedChannels.set(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
xtream_id: index + 1,
|
||||
title: `Channel ${index + 1}`,
|
||||
}))
|
||||
);
|
||||
selectedItem.set({ xtream_id: 16 });
|
||||
fixture.detectChanges();
|
||||
const viewport = fixture.componentInstance.viewport();
|
||||
if (!viewport) throw new Error('Expected channel viewport');
|
||||
const scroll = jest.spyOn(viewport, 'scrollToIndex');
|
||||
const focus = jest.spyOn(viewport.elementRef.nativeElement, 'focus');
|
||||
fixture.componentRef.setInput('revealRequest', {
|
||||
channelId: 16,
|
||||
sequence: 1,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
const frame =
|
||||
requestFrame.mock.calls[requestFrame.mock.calls.length - 1]?.[0];
|
||||
frame?.(0);
|
||||
requestFrame.mockRestore();
|
||||
expect(scroll).toHaveBeenCalledWith(15, 'auto');
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
scroll.mockClear();
|
||||
selectedChannels.set([...selectedChannels()]);
|
||||
fixture.detectChanges();
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start smooth alignment when a selected row is already visible', () => {
|
||||
selectedTypeContentLoading.set(false);
|
||||
selectedChannels.set(
|
||||
|
||||
+35
@@ -4,6 +4,7 @@ import {
|
||||
ScrollingModule,
|
||||
} from '@angular/cdk/scrolling';
|
||||
import {
|
||||
afterRenderEffect,
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
@@ -105,6 +106,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
* invalid and could point that hand-off at the hidden copy.
|
||||
*/
|
||||
readonly fullscreenPanelCopy = input(false);
|
||||
readonly revealRequest = input<{
|
||||
channelId: number;
|
||||
sequence: number;
|
||||
} | null>(null);
|
||||
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly favoritesService = inject(FavoritesService);
|
||||
@@ -190,6 +195,36 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
untracked(() => this.repickPreviewsForOffsetChange());
|
||||
});
|
||||
|
||||
let appliedReveal = -1;
|
||||
// A filtered-out viewport is recreated on reveal. CDK attaches its
|
||||
// scroll strategy in a microtask after rendering; align on the next
|
||||
// frame so scrollToIndex cannot silently run before that attachment.
|
||||
afterRenderEffect((onCleanup) => {
|
||||
const request = this.revealRequest();
|
||||
const viewport = this.viewport();
|
||||
const channels = this.filteredChannels();
|
||||
if (!request || !viewport || appliedReveal === request.sequence)
|
||||
return;
|
||||
const index = channels.findIndex(
|
||||
(item) => item.xtream_id === request.channelId
|
||||
);
|
||||
if (index < 0) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (
|
||||
this.revealRequest()?.sequence !== request.sequence ||
|
||||
this.viewport() !== viewport
|
||||
)
|
||||
return;
|
||||
appliedReveal = request.sequence;
|
||||
viewport.checkViewportSize();
|
||||
viewport.scrollToIndex(index, 'auto');
|
||||
viewport.elementRef.nativeElement.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
});
|
||||
onCleanup(() => cancelAnimationFrame(frame));
|
||||
});
|
||||
|
||||
const selectedChannelId = computed(() =>
|
||||
Number(
|
||||
(
|
||||
|
||||
@@ -11,10 +11,30 @@ import { TIMELINE_MINUTE_MS, TimelineBlock } from './epg-timeline.utils';
|
||||
export const TIMELINE_MIN_BLOCK_WIDTH_PX = 40;
|
||||
/** Gap subtracted from each block so neighbours don't touch. */
|
||||
export const TIMELINE_BLOCK_GAP_PX = 4;
|
||||
/** Zoom (px/min) slider bounds. */
|
||||
/** Zoom (px/min) bounds for the Ctrl/⌘ + wheel fine-tune gesture. */
|
||||
export const TIMELINE_ZOOM_MIN = 0.8;
|
||||
export const TIMELINE_ZOOM_MAX = 6;
|
||||
export const TIMELINE_ZOOM_STEP = 0.1;
|
||||
/**
|
||||
* Multiplicative zoom per wheel pixel: one mouse notch (~100px) is about
|
||||
* ×1.22, and a trackpad pinch (a few px per event) stays smooth.
|
||||
*/
|
||||
export const TIMELINE_WHEEL_ZOOM_RATE = 0.002;
|
||||
|
||||
export type TimelineZoomLevel = 'day' | 'hours' | 'detail';
|
||||
|
||||
/**
|
||||
* The three zoom presets the toolbar button cycles through. `hours` is the
|
||||
* default scale; `detail` matches the zoom a group chip expands to, so the
|
||||
* cycle keeps reading consistently after an expand.
|
||||
*/
|
||||
export const TIMELINE_ZOOM_LEVELS: readonly {
|
||||
readonly level: TimelineZoomLevel;
|
||||
readonly scale: number;
|
||||
}[] = [
|
||||
{ level: 'day', scale: 1 },
|
||||
{ level: 'hours', scale: 1.75 },
|
||||
{ level: 'detail', scale: 3.4 },
|
||||
];
|
||||
/** Below this zoom, dense runs of short programmes collapse into a group chip. */
|
||||
export const TIMELINE_GROUP_ZOOM_MAX = 1.3;
|
||||
/** Zoom applied when a group chip is expanded. */
|
||||
@@ -69,6 +89,24 @@ export function tierFor(widthPx: number): TimelineTier {
|
||||
return 'micro';
|
||||
}
|
||||
|
||||
/** Which preset band a (possibly wheel-tuned) scale falls into. */
|
||||
export function timelineZoomLevelForScale(scale: number): TimelineZoomLevel {
|
||||
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'day';
|
||||
if (scale < 3) return 'hours';
|
||||
return 'detail';
|
||||
}
|
||||
|
||||
/** The preset after the current band, wrapping detail → day. */
|
||||
export function nextTimelineZoomScale(scale: number): number {
|
||||
const current = timelineZoomLevelForScale(scale);
|
||||
const index = TIMELINE_ZOOM_LEVELS.findIndex(
|
||||
(entry) => entry.level === current
|
||||
);
|
||||
const next =
|
||||
TIMELINE_ZOOM_LEVELS[(index + 1) % TIMELINE_ZOOM_LEVELS.length];
|
||||
return next.scale;
|
||||
}
|
||||
|
||||
/** Adaptive tick spacing (minutes) — denser as the user zooms in. */
|
||||
export function timelineTickStepForScale(scale: number): number {
|
||||
if (scale < 2.2) return 120;
|
||||
@@ -151,7 +189,8 @@ export function buildTimelineRenderItems(
|
||||
if (run.length >= TIMELINE_GROUP_MIN_RUN) {
|
||||
const first = run[0];
|
||||
const last = run[run.length - 1];
|
||||
const spanPx = ((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
|
||||
const spanPx =
|
||||
((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
|
||||
items.push({
|
||||
kind: 'group',
|
||||
key: `group-${first.key}-${last.key}`,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { TimelineZoomController } from './epg-timeline-zoom.controller';
|
||||
import {
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
} from './epg-timeline-render.util';
|
||||
|
||||
function fakeScroller(clientWidth: number, scrollLeft: number): HTMLElement {
|
||||
return {
|
||||
clientWidth,
|
||||
scrollLeft,
|
||||
getBoundingClientRect: () => ({ left: 100 }),
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
describe('TimelineZoomController', () => {
|
||||
let scale: number;
|
||||
let scroller: HTMLElement | undefined;
|
||||
let controller: TimelineZoomController;
|
||||
let rafCallbacks: FrameRequestCallback[];
|
||||
|
||||
beforeEach(() => {
|
||||
scale = 2;
|
||||
scroller = fakeScroller(600, 1200);
|
||||
rafCallbacks = [];
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
controller = new TimelineZoomController({
|
||||
ribbon: () => scroller,
|
||||
scale: () => scale,
|
||||
setScale: (next) => (scale = next),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function flushFrames(): void {
|
||||
for (const cb of rafCallbacks.splice(0)) cb(0);
|
||||
}
|
||||
|
||||
it('keeps the viewport centre minute fixed on a programmatic zoom', () => {
|
||||
// centre = (1200 + 300) / 2 px/min = 750 min
|
||||
controller.zoomTo(4);
|
||||
flushFrames();
|
||||
expect(scale).toBe(4);
|
||||
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
|
||||
});
|
||||
|
||||
it('clamps to the zoom bounds and ignores non-finite input', () => {
|
||||
controller.zoomTo(99);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MAX);
|
||||
controller.zoomTo(-1);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MIN);
|
||||
controller.zoomTo(Number.NaN);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MIN);
|
||||
});
|
||||
|
||||
it('does nothing when the clamped scale is unchanged', () => {
|
||||
controller.zoomTo(2);
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('zooms around the cursor on Ctrl + wheel', () => {
|
||||
// cursor 150px into the 600px viewport → anchor 0.25
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
ctrlKey: true,
|
||||
clientX: 250,
|
||||
cancelable: true,
|
||||
});
|
||||
controller.onWheel(event);
|
||||
flushFrames();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(scale).toBeCloseTo(2 * Math.exp(0.2), 6);
|
||||
// minute under the cursor before: (1200 + 150) / 2 = 675
|
||||
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
|
||||
});
|
||||
|
||||
it('keeps the cursor minute anchored across a burst of wheel events', () => {
|
||||
const burst = () =>
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: -50,
|
||||
ctrlKey: true,
|
||||
clientX: 250,
|
||||
cancelable: true,
|
||||
});
|
||||
// Three events before any frame runs: the DOM scrollLeft is stale for
|
||||
// the 2nd and 3rd, so the anchor must come from the logical position.
|
||||
controller.onWheel(burst());
|
||||
controller.onWheel(burst());
|
||||
controller.onWheel(burst());
|
||||
expect(rafCallbacks).toHaveLength(1); // coalesced into one frame
|
||||
flushFrames();
|
||||
// minute under the cursor before the burst: (1200 + 150) / 2 = 675
|
||||
expect(scale).toBeCloseTo(2 * Math.exp(0.3), 6);
|
||||
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
|
||||
});
|
||||
|
||||
it('re-anchors on the DOM position once a frame has flushed', () => {
|
||||
controller.zoomTo(4);
|
||||
flushFrames();
|
||||
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
|
||||
controller.zoomTo(2);
|
||||
flushFrames();
|
||||
expect(scroller?.scrollLeft).toBe(750 * 2 - 300);
|
||||
});
|
||||
|
||||
it('leaves plain wheel events alone', () => {
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
cancelable: true,
|
||||
});
|
||||
controller.onWheel(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(scale).toBe(2);
|
||||
});
|
||||
|
||||
it('zooms without a rendered ribbon', () => {
|
||||
scroller = undefined;
|
||||
controller.cycle();
|
||||
expect(scale).toBe(3.4);
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
nextTimelineZoomScale,
|
||||
TIMELINE_WHEEL_ZOOM_RATE,
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
} from './epg-timeline-render.util';
|
||||
|
||||
export interface TimelineZoomContext {
|
||||
/** The horizontal scroller hosting the track (undefined until rendered). */
|
||||
readonly ribbon: () => HTMLElement | undefined;
|
||||
readonly scale: () => number;
|
||||
readonly setScale: (scale: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ribbon zoom (px per minute) owned by the toolbar button and the
|
||||
* Ctrl/⌘ + wheel gesture. Every change keeps one ribbon minute anchored on
|
||||
* screen: the viewport centre for button/programmatic zooms, the cursor for
|
||||
* wheel zooms.
|
||||
*/
|
||||
export class TimelineZoomController {
|
||||
/**
|
||||
* Scroll position the next animation frame will apply. Rapid wheel/pinch
|
||||
* events arrive faster than frames, so the DOM `scrollLeft` still reflects
|
||||
* the previous scale by the time the next event computes its anchor; the
|
||||
* logical position keeps every event anchored on the same minute.
|
||||
*/
|
||||
private pendingScrollLeft: number | null = null;
|
||||
private frame = 0;
|
||||
|
||||
constructor(private readonly ctx: TimelineZoomContext) {}
|
||||
|
||||
/** Clamp + apply a scale, keeping the viewport centre stable. */
|
||||
zoomTo(value: number): void {
|
||||
this.applyScale(value, 0.5);
|
||||
}
|
||||
|
||||
/** Toolbar zoom button: step to the next preset (day → hours → detail). */
|
||||
cycle(): void {
|
||||
this.applyScale(nextTimelineZoomScale(this.ctx.scale()), 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/⌘ + wheel (and trackpad pinch, which Chromium reports the same
|
||||
* way) zooms around the cursor. Plain wheel keeps scrolling.
|
||||
* `preventDefault` also stops Chromium's page zoom for the same gesture.
|
||||
*/
|
||||
onWheel(event: WheelEvent): void {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const scroller = this.ctx.ribbon();
|
||||
const anchor = scroller
|
||||
? (event.clientX - scroller.getBoundingClientRect().left) /
|
||||
scroller.clientWidth
|
||||
: 0.5;
|
||||
const factor = Math.exp(-event.deltaY * TIMELINE_WHEEL_ZOOM_RATE);
|
||||
this.applyScale(this.ctx.scale() * factor, anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the scale so the ribbon minute under `anchorFrac` (0 = left edge,
|
||||
* 1 = right edge of the viewport) stays put.
|
||||
*/
|
||||
private applyScale(value: number, anchorFrac: number): void {
|
||||
const requested = Number(value);
|
||||
const prev = this.ctx.scale();
|
||||
const next = Number.isFinite(requested)
|
||||
? Math.min(
|
||||
TIMELINE_ZOOM_MAX,
|
||||
Math.max(TIMELINE_ZOOM_MIN, requested)
|
||||
)
|
||||
: prev;
|
||||
if (next === prev) {
|
||||
return;
|
||||
}
|
||||
const scroller = this.ctx.ribbon();
|
||||
this.ctx.setScale(next);
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
const anchorPx = scroller.clientWidth * anchorFrac;
|
||||
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
|
||||
const anchorMin = (currentLeft + anchorPx) / prev;
|
||||
this.pendingScrollLeft = anchorMin * next - anchorPx;
|
||||
if (this.frame === 0) {
|
||||
this.frame = requestAnimationFrame(() => this.flushScroll());
|
||||
}
|
||||
}
|
||||
|
||||
/** One frame applies the last coalesced position for a burst of events. */
|
||||
private flushScroll(): void {
|
||||
this.frame = 0;
|
||||
const left = this.pendingScrollLeft;
|
||||
this.pendingScrollLeft = null;
|
||||
const scroller = this.ctx.ribbon();
|
||||
if (scroller && left !== null) {
|
||||
scroller.scrollLeft = left;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,10 @@
|
||||
summary()?.title
|
||||
}}</span>
|
||||
@if (progress() !== null) {
|
||||
<span class="epg-timeline__summary-bar" aria-hidden="true">
|
||||
<span
|
||||
class="epg-timeline__summary-bar"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i [style.width.%]="progress()"></i>
|
||||
</span>
|
||||
}
|
||||
@@ -99,29 +102,26 @@
|
||||
@if (showRibbonControls()) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-timeline__jump"
|
||||
class="epg-timeline__iconbtn epg-timeline__jump"
|
||||
(click)="jumpToNow()"
|
||||
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
|
||||
[attr.aria-label]="'EPG.TIMELINE.JUMP_NOW' | translate"
|
||||
>
|
||||
<mat-icon>my_location</mat-icon>
|
||||
{{ 'EPG.TIMELINE.NOW' | translate }}
|
||||
</button>
|
||||
<label
|
||||
class="epg-timeline__zoom"
|
||||
[matTooltip]="zoomLabelKey() | translate"
|
||||
@let zoomTooltip =
|
||||
'EPG.TIMELINE.ZOOM_TOOLTIP'
|
||||
| translate: { level: (zoomLabelKey() | translate) };
|
||||
<button
|
||||
type="button"
|
||||
class="epg-timeline__iconbtn epg-timeline__zoom"
|
||||
[attr.data-zoom-level]="zoomLevel()"
|
||||
(click)="cycleZoom()"
|
||||
[matTooltip]="zoomTooltip"
|
||||
[attr.aria-label]="zoomTooltip"
|
||||
>
|
||||
<mat-icon>zoom_in</mat-icon>
|
||||
<input
|
||||
#zoomInput
|
||||
type="range"
|
||||
[min]="zoomMin"
|
||||
[max]="zoomMax"
|
||||
[step]="zoomStep"
|
||||
[value]="scale()"
|
||||
(input)="onZoom(zoomInput.valueAsNumber)"
|
||||
[attr.aria-label]="'EPG.TIMELINE.ZOOM' | translate"
|
||||
/>
|
||||
</label>
|
||||
<mat-icon>{{ zoomIcon() }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (showDateStepper()) {
|
||||
<div class="epg-timeline__stepper">
|
||||
@@ -145,7 +145,8 @@
|
||||
}
|
||||
</span>
|
||||
<small>{{
|
||||
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
|
||||
viewDate()
|
||||
| date: 'EEE, d MMM' : '' : currentLocale()
|
||||
}}</small>
|
||||
</div>
|
||||
<button
|
||||
@@ -166,13 +167,17 @@
|
||||
@if (state === 'ribbon' && !archivePlaybackAvailable()) {
|
||||
<div class="epg-timeline__notice" role="note">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
|
||||
<span>{{
|
||||
'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (state) {
|
||||
@case ('loading') {
|
||||
<div class="epg-timeline__ribbon epg-timeline__ribbon--skeleton">
|
||||
<div
|
||||
class="epg-timeline__ribbon epg-timeline__ribbon--skeleton"
|
||||
>
|
||||
@for (width of skeletonWidths; track $index) {
|
||||
<div
|
||||
class="epg-timeline__sk"
|
||||
@@ -186,6 +191,7 @@
|
||||
#ribbon
|
||||
class="epg-timeline__ribbon app-scrollbar"
|
||||
(scroll)="onRibbonScroll()"
|
||||
(wheel)="onRibbonWheel($event)"
|
||||
>
|
||||
<app-epg-timeline-track
|
||||
[items]="renderItems()"
|
||||
|
||||
@@ -53,8 +53,10 @@
|
||||
padding: 4px 6px;
|
||||
border-radius: 7px;
|
||||
font: inherit;
|
||||
/* Shrinks only when the toolbar overflows: the channel + programme title
|
||||
take every pixel the fixed-width controls leave (no hard width cap). */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 60%;
|
||||
|
||||
&:hover {
|
||||
background: $surface-2;
|
||||
@@ -161,32 +163,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.epg-timeline__jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
/* icon-only toolbar buttons: "Now" + zoom level (label lives in the tooltip
|
||||
and aria-label, so the channel/programme title keeps the toolbar width) */
|
||||
.epg-timeline__iconbtn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line-strong;
|
||||
color: $text-secondary;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
border-color: $accent-blue;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-timeline__zoom[data-zoom-level='detail'] {
|
||||
color: $accent-text;
|
||||
}
|
||||
|
||||
.epg-timeline__stepper {
|
||||
@@ -339,32 +349,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── zoom slider ── */
|
||||
.epg-timeline__zoom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line;
|
||||
color: $text-tertiary;
|
||||
flex: 0 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 96px;
|
||||
accent-color: $accent-text;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ribbon ── */
|
||||
.epg-timeline__ribbon {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -40,7 +40,9 @@ describe('EpgTimelineComponent', () => {
|
||||
providers: [
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: { open: () => ({ afterClosed: () => of(undefined) }) },
|
||||
useValue: {
|
||||
open: () => ({ afterClosed: () => of(undefined) }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -225,6 +227,60 @@ describe('EpgTimelineComponent', () => {
|
||||
expect(component.renderItems().length).toBe(before);
|
||||
});
|
||||
|
||||
it('cycles the zoom button through day → hours → detail → day', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
expect(component.zoomLevel()).toBe('hours'); // default scale
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('detail');
|
||||
expect(component.zoomIcon()).toBe('zoom_in');
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('day');
|
||||
expect(component.zoomIcon()).toBe('zoom_out');
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('hours');
|
||||
expect(component.zoomLabelKey()).toBe('EPG.TIMELINE.ZOOM_HOURS');
|
||||
});
|
||||
|
||||
it('snaps a wheel-tuned scale to the next preset band when cycling', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
component.onZoom(2.6); // still "hours" band, but off-preset
|
||||
expect(component.zoomLevel()).toBe('hours');
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('detail');
|
||||
expect(component.scale()).toBe(3.4);
|
||||
});
|
||||
|
||||
function wheel(init: WheelEventInit): WheelEvent {
|
||||
return new WheelEvent('wheel', { cancelable: true, ...init });
|
||||
}
|
||||
|
||||
it('zooms the ribbon with Ctrl/⌘ + wheel and blocks page zoom', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
const before = component.scale();
|
||||
|
||||
const zoomIn = wheel({ deltaY: -100, ctrlKey: true });
|
||||
component.onRibbonWheel(zoomIn);
|
||||
expect(zoomIn.defaultPrevented).toBe(true);
|
||||
expect(component.scale()).toBeGreaterThan(before);
|
||||
|
||||
const zoomOut = wheel({ deltaY: 300, metaKey: true });
|
||||
component.onRibbonWheel(zoomOut);
|
||||
expect(component.scale()).toBeLessThan(before);
|
||||
expect(component.scale()).toBeGreaterThanOrEqual(component.zoomMin);
|
||||
});
|
||||
|
||||
it('leaves a plain wheel to native scrolling', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
const before = component.scale();
|
||||
const plain = wheel({ deltaY: -100 });
|
||||
component.onRibbonWheel(plain);
|
||||
expect(plain.defaultPrevented).toBe(false);
|
||||
expect(component.scale()).toBe(before);
|
||||
});
|
||||
|
||||
it('expands a group chip by zooming in', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
component.onGroupExpand({
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
EpgTimelineEmptyStateComponent,
|
||||
} from './epg-timeline-empty-state.component';
|
||||
import { TimelineScrollController } from './epg-timeline-scroll.controller';
|
||||
import { TimelineZoomController } from './epg-timeline-zoom.controller';
|
||||
import { EpgTimelineTrackComponent } from './epg-timeline-track.component';
|
||||
import {
|
||||
buildTimelineAxis,
|
||||
@@ -59,10 +60,10 @@ import {
|
||||
TIMELINE_MINUTE_MS,
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
TIMELINE_ZOOM_STEP,
|
||||
TimelineBlock,
|
||||
TimelineRenderGroup,
|
||||
timelineTickStepForScale,
|
||||
timelineZoomLevelForScale,
|
||||
} from './epg-timeline.utils';
|
||||
|
||||
type RenderState = 'loading' | 'ribbon' | EpgTimelineEmptyReason;
|
||||
@@ -115,7 +116,6 @@ export class EpgTimelineComponent {
|
||||
readonly scale = signal(TIMELINE_DEFAULT_SCALE);
|
||||
readonly zoomMin = TIMELINE_ZOOM_MIN;
|
||||
readonly zoomMax = TIMELINE_ZOOM_MAX;
|
||||
readonly zoomStep = TIMELINE_ZOOM_STEP;
|
||||
readonly skeletonWidths = [120, 170, 150, 200, 140, 180];
|
||||
|
||||
private readonly nowMs = signal(Date.now());
|
||||
@@ -127,6 +127,13 @@ export class EpgTimelineComponent {
|
||||
return key ? key : getTodayEpgDateKey();
|
||||
});
|
||||
|
||||
/** Zoom button + Ctrl/⌘ wheel, anchored so a ribbon minute stays put. */
|
||||
private readonly zoom = new TimelineZoomController({
|
||||
ribbon: () => this.ribbon()?.nativeElement,
|
||||
scale: () => this.scale(),
|
||||
setScale: (scale) => this.scale.set(scale),
|
||||
});
|
||||
|
||||
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
|
||||
private readonly scroll = new TimelineScrollController({
|
||||
ribbon: () => this.ribbon()?.nativeElement,
|
||||
@@ -196,12 +203,23 @@ export class EpgTimelineComponent {
|
||||
((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
|
||||
);
|
||||
});
|
||||
readonly zoomLevel = computed(() =>
|
||||
timelineZoomLevelForScale(this.scale())
|
||||
);
|
||||
readonly zoomLabelKey = computed(() => {
|
||||
const scale = this.scale();
|
||||
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'EPG.TIMELINE.ZOOM_DAY';
|
||||
if (scale < 3) return 'EPG.TIMELINE.ZOOM_HOURS';
|
||||
return 'EPG.TIMELINE.ZOOM_DETAIL';
|
||||
switch (this.zoomLevel()) {
|
||||
case 'day':
|
||||
return 'EPG.TIMELINE.ZOOM_DAY';
|
||||
case 'hours':
|
||||
return 'EPG.TIMELINE.ZOOM_HOURS';
|
||||
default:
|
||||
return 'EPG.TIMELINE.ZOOM_DETAIL';
|
||||
}
|
||||
});
|
||||
/** `zoom_out` reads as "overview"; both finer bands share `zoom_in`. */
|
||||
readonly zoomIcon = computed(() =>
|
||||
this.zoomLevel() === 'day' ? 'zoom_out' : 'zoom_in'
|
||||
);
|
||||
readonly nowLabel = computed(() => formatClockTime(this.nowMs()));
|
||||
|
||||
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
|
||||
@@ -301,24 +319,19 @@ export class EpgTimelineComponent {
|
||||
this.collapsedChange.emit(!this.collapsed());
|
||||
}
|
||||
|
||||
/** Clamp + apply a scale, keeping the viewport centre stable. */
|
||||
onZoom(value: number): void {
|
||||
const requested = Number(value);
|
||||
const next = Number.isFinite(requested)
|
||||
? Math.min(this.zoomMax, Math.max(this.zoomMin, requested))
|
||||
: this.scale();
|
||||
// Keep the viewport centre stable across a zoom change.
|
||||
const scroller = this.ribbon()?.nativeElement;
|
||||
const prev = this.scale();
|
||||
const centreMin = scroller
|
||||
? (scroller.scrollLeft + scroller.clientWidth / 2) / prev
|
||||
: null;
|
||||
this.scale.set(next);
|
||||
if (scroller && centreMin !== null) {
|
||||
requestAnimationFrame(() => {
|
||||
scroller.scrollLeft =
|
||||
centreMin * next - scroller.clientWidth / 2;
|
||||
});
|
||||
}
|
||||
this.zoom.zoomTo(value);
|
||||
}
|
||||
|
||||
/** Toolbar zoom button: next preset (day → hours → detail → day). */
|
||||
cycleZoom(): void {
|
||||
this.zoom.cycle();
|
||||
}
|
||||
|
||||
/** Ctrl/⌘ + wheel over the ribbon zooms around the cursor. */
|
||||
onRibbonWheel(event: WheelEvent): void {
|
||||
this.zoom.onWheel(event);
|
||||
}
|
||||
|
||||
onGroupExpand(group: TimelineRenderGroup): void {
|
||||
|
||||
@@ -9,12 +9,16 @@ import {
|
||||
dayKeyAtOffset,
|
||||
hasProgramsForDateKey,
|
||||
nearestDateKeyWithPrograms,
|
||||
nextTimelineZoomScale,
|
||||
TIMELINE_GROUP_EXPAND_ZOOM,
|
||||
TIMELINE_MINUTE_MS,
|
||||
TIMELINE_MIN_BLOCK_WIDTH_PX,
|
||||
TIMELINE_ZOOM_LEVELS,
|
||||
TimelineRenderBlock,
|
||||
TimelineRenderGroup,
|
||||
tierFor,
|
||||
timelineTickStepForScale,
|
||||
timelineZoomLevelForScale,
|
||||
} from './epg-timeline.utils';
|
||||
|
||||
function localIso(
|
||||
@@ -66,15 +70,15 @@ describe('epg-timeline.utils', () => {
|
||||
|
||||
describe('classifyTimelineWhen', () => {
|
||||
it('classifies past, now and future relative to now', () => {
|
||||
expect(classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)).toBe(
|
||||
'past'
|
||||
);
|
||||
expect(
|
||||
classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)
|
||||
).toBe('past');
|
||||
expect(classifyTimelineWhen(NOW - 60000, NOW + 60000, NOW)).toBe(
|
||||
'now'
|
||||
);
|
||||
expect(classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)).toBe(
|
||||
'future'
|
||||
);
|
||||
expect(
|
||||
classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)
|
||||
).toBe('future');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,18 +95,30 @@ describe('epg-timeline.utils', () => {
|
||||
const block = blocks[0];
|
||||
expect(block.when).toBe('now');
|
||||
expect(block.durationMin).toBe(120);
|
||||
const expectedOffset = (block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
|
||||
const expectedOffset =
|
||||
(block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
|
||||
expect(block.offsetMin).toBeCloseTo(expectedOffset, 5);
|
||||
});
|
||||
|
||||
it('sorts blocks chronologically', () => {
|
||||
const programs = [
|
||||
program(localIso(2026, 6, 28, 18), localIso(2026, 6, 28, 19), 'late'),
|
||||
program(localIso(2026, 6, 28, 8), localIso(2026, 6, 28, 9), 'early'),
|
||||
program(
|
||||
localIso(2026, 6, 28, 18),
|
||||
localIso(2026, 6, 28, 19),
|
||||
'late'
|
||||
),
|
||||
program(
|
||||
localIso(2026, 6, 28, 8),
|
||||
localIso(2026, 6, 28, 9),
|
||||
'early'
|
||||
),
|
||||
];
|
||||
const axis = buildTimelineAxis(programs, NOW);
|
||||
const blocks = buildTimelineBlocks(programs, axis, NOW);
|
||||
expect(blocks.map((b) => b.program.title)).toEqual(['early', 'late']);
|
||||
expect(blocks.map((b) => b.program.title)).toEqual([
|
||||
'early',
|
||||
'late',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +183,8 @@ describe('epg-timeline.utils', () => {
|
||||
[program(localIso(2026, 6, 28, 10), localIso(2026, 6, 28, 11))],
|
||||
NOW
|
||||
);
|
||||
const offsetForNoon = (12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
|
||||
const offsetForNoon =
|
||||
(12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
|
||||
expect(dayKeyAtOffset(axis, offsetForNoon)).toBe('2026-06-28');
|
||||
});
|
||||
|
||||
@@ -177,7 +194,9 @@ describe('epg-timeline.utils', () => {
|
||||
program(localIso(2026, 6, 30, 10), localIso(2026, 6, 30, 11)),
|
||||
];
|
||||
// Jun 30 10:00 (~1d22h away) is closer to Jun 28 12:00 than Jun 25.
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe('2026-06-30');
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe(
|
||||
'2026-06-30'
|
||||
);
|
||||
});
|
||||
|
||||
it('keys, positions and picks days in display time when an offset is set', () => {
|
||||
@@ -189,8 +208,12 @@ describe('epg-timeline.utils', () => {
|
||||
'Late'
|
||||
),
|
||||
];
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(false);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(true);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(
|
||||
false
|
||||
);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(
|
||||
true
|
||||
);
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW, 60)).toBe(
|
||||
'2026-06-30'
|
||||
);
|
||||
@@ -205,6 +228,35 @@ describe('epg-timeline.utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoom presets', () => {
|
||||
it('maps every preset scale back to its own level', () => {
|
||||
for (const { level, scale } of TIMELINE_ZOOM_LEVELS) {
|
||||
expect(timelineZoomLevelForScale(scale)).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies off-preset scales by band', () => {
|
||||
expect(timelineZoomLevelForScale(0.8)).toBe('day');
|
||||
expect(timelineZoomLevelForScale(1.29)).toBe('day');
|
||||
expect(timelineZoomLevelForScale(1.3)).toBe('hours');
|
||||
expect(timelineZoomLevelForScale(2.99)).toBe('hours');
|
||||
expect(timelineZoomLevelForScale(3)).toBe('detail');
|
||||
expect(timelineZoomLevelForScale(6)).toBe('detail');
|
||||
});
|
||||
|
||||
it('cycles to the next preset and wraps from detail to day', () => {
|
||||
expect(nextTimelineZoomScale(1)).toBe(1.75);
|
||||
expect(nextTimelineZoomScale(1.75)).toBe(3.4);
|
||||
expect(nextTimelineZoomScale(3.4)).toBe(1);
|
||||
// an expanded group chip sits on the detail preset, so the cycle
|
||||
// continues from there instead of re-entering "detail"
|
||||
expect(nextTimelineZoomScale(TIMELINE_GROUP_EXPAND_ZOOM)).toBe(1);
|
||||
// wheel-tuned values snap to the band's successor
|
||||
expect(nextTimelineZoomScale(2.6)).toBe(3.4);
|
||||
expect(nextTimelineZoomScale(5)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('short-programme strategy', () => {
|
||||
it('classifies content tiers by rendered width', () => {
|
||||
expect(tierFor(200)).toBe('wide');
|
||||
@@ -220,11 +272,19 @@ describe('epg-timeline.utils', () => {
|
||||
expect(timelineTickStepForScale(6)).toBe(15);
|
||||
});
|
||||
|
||||
function blocksFor(durations: number[]): ReturnType<typeof buildTimelineBlocks> {
|
||||
function blocksFor(
|
||||
durations: number[]
|
||||
): ReturnType<typeof buildTimelineBlocks> {
|
||||
let startMin = 6 * 60; // 06:00
|
||||
const programs = durations.map((dur) => {
|
||||
const p = program(
|
||||
localIso(2026, 6, 28, Math.floor(startMin / 60), startMin % 60),
|
||||
localIso(
|
||||
2026,
|
||||
6,
|
||||
28,
|
||||
Math.floor(startMin / 60),
|
||||
startMin % 60
|
||||
),
|
||||
localIso(
|
||||
2026,
|
||||
6,
|
||||
@@ -278,7 +338,10 @@ describe('epg-timeline.utils', () => {
|
||||
eh: number,
|
||||
em: number
|
||||
): ReturnType<typeof program> =>
|
||||
program(localIso(2026, 6, 28, sh, sm), localIso(2026, 6, 28, eh, em));
|
||||
program(
|
||||
localIso(2026, 6, 28, sh, sm),
|
||||
localIso(2026, 6, 28, eh, em)
|
||||
);
|
||||
const programs = [
|
||||
mk(11, 38, 11, 43),
|
||||
mk(11, 43, 11, 48),
|
||||
|
||||
Reference in new issue
Block a user