From 5febe28ebaca1505c052f9022443684d1258c22d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:59:28 +0200 Subject: [PATCH 1/4] fix(web-backend): validate and pin provider redirect hops (#1553) * fix(web-backend): validate and pin every provider redirect hop * fix(web-backend): separate provider metadata from connection authority --- .changes/web-backend-validated-redirects.md | 7 + AGENTS.md | 13 + CLAUDE.md | 13 + apps/web-backend/src/app/host-guard.ts | 24 +- .../src/app/provider-axios-transport.ts | 108 ++++++ apps/web-backend/src/app/provider-error.ts | 11 + .../src/app/provider-request-error.ts | 12 + apps/web-backend/src/app/provider-response.ts | 51 +++ .../src/app/provider-url-policy.spec.ts | 117 +++++++ .../src/app/provider-url-policy.ts | 140 ++++++++ .../src/app/testing/provider-test.key | 28 ++ .../src/app/testing/provider-test.pem | 20 ++ .../validated-http-client.integration.spec.ts | 323 ++++++++++++++++++ .../src/app/validated-http-client.spec.ts | 198 +++++++++++ .../src/app/validated-http-client.ts | 216 ++++++++++++ .../app/web-backend-app.host-guard.spec.ts | 79 ++--- .../src/app/web-backend-app.redirects.spec.ts | 238 +++++++++++++ .../src/app/web-backend-app.spec-helpers.ts | 32 +- .../src/app/web-backend-app.spec.ts | 2 +- apps/web-backend/src/app/web-backend-app.ts | 273 ++------------- apps/web-e2e/project.json | 1 + docs/architecture/host-connectivity-guard.md | 56 ++- docs/architecture/nx-workspace-boundaries.md | 6 + docs/architecture/pwa-self-hosted.md | 57 ++++ 24 files changed, 1694 insertions(+), 331 deletions(-) create mode 100644 .changes/web-backend-validated-redirects.md create mode 100644 apps/web-backend/src/app/provider-axios-transport.ts create mode 100644 apps/web-backend/src/app/provider-request-error.ts create mode 100644 apps/web-backend/src/app/provider-response.ts create mode 100644 apps/web-backend/src/app/provider-url-policy.spec.ts create mode 100644 apps/web-backend/src/app/provider-url-policy.ts create mode 100644 apps/web-backend/src/app/testing/provider-test.key create mode 100644 apps/web-backend/src/app/testing/provider-test.pem create mode 100644 apps/web-backend/src/app/validated-http-client.integration.spec.ts create mode 100644 apps/web-backend/src/app/validated-http-client.spec.ts create mode 100644 apps/web-backend/src/app/validated-http-client.ts create mode 100644 apps/web-backend/src/app/web-backend-app.redirects.spec.ts diff --git a/.changes/web-backend-validated-redirects.md b/.changes/web-backend-validated-redirects.md new file mode 100644 index 000000000..83c83a659 --- /dev/null +++ b/.changes/web-backend-validated-redirects.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 4af88313b..fafb342ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 22a023963..b7af8b73f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1692,6 +1692,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 diff --git a/apps/web-backend/src/app/host-guard.ts b/apps/web-backend/src/app/host-guard.ts index d4d710656..78ea157b1 100644 --- a/apps/web-backend/src/app/host-guard.ts +++ b/apps/web-backend/src/app/host-guard.ts @@ -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; } diff --git a/apps/web-backend/src/app/provider-axios-transport.ts b/apps/web-backend/src/app/provider-axios-transport.ts new file mode 100644 index 000000000..99b6aaae0 --- /dev/null +++ b/apps/web-backend/src/app/provider-axios-transport.ts @@ -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( + url: string, + options: ProviderTransportOptions = {} + ): Promise> { + 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(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); +} diff --git a/apps/web-backend/src/app/provider-error.ts b/apps/web-backend/src/app/provider-error.ts index 275e78706..9a64b9cd0 100644 --- a/apps/web-backend/src/app/provider-error.ts +++ b/apps/web-backend/src/app/provider-error.ts @@ -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 ( diff --git a/apps/web-backend/src/app/provider-request-error.ts b/apps/web-backend/src/app/provider-request-error.ts new file mode 100644 index 000000000..350e3c12b --- /dev/null +++ b/apps/web-backend/src/app/provider-request-error.ts @@ -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'); + } +} diff --git a/apps/web-backend/src/app/provider-response.ts b/apps/web-backend/src/app/provider-response.ts new file mode 100644 index 000000000..51400173f --- /dev/null +++ b/apps/web-backend/src/app/provider-response.ts @@ -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( + data: T, + arraybuffer: boolean, + timeout?: number, + socket?: Socket +): Promise { + 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; + } +} diff --git a/apps/web-backend/src/app/provider-url-policy.spec.ts b/apps/web-backend/src/app/provider-url-policy.spec.ts new file mode 100644 index 000000000..ef8b7c8dd --- /dev/null +++ b/apps/web-backend/src/app/provider-url-policy.spec.ts @@ -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 }); + }); +}); diff --git a/apps/web-backend/src/app/provider-url-policy.ts b/apps/web-backend/src/app/provider-url-policy.ts new file mode 100644 index 000000000..862cf6536 --- /dev/null +++ b/apps/web-backend/src/app/provider-url-policy.ts @@ -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; +} + +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 { + const records = await lookup(hostname, { all: true, verbatim: true }); + return records.map((record) => record.address); +} + +export async function validateProviderUrl( + rawUrl: string, + policy: ProviderUrlPolicy +): Promise { + 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'); +} diff --git a/apps/web-backend/src/app/testing/provider-test.key b/apps/web-backend/src/app/testing/provider-test.key new file mode 100644 index 000000000..64260c928 --- /dev/null +++ b/apps/web-backend/src/app/testing/provider-test.key @@ -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----- diff --git a/apps/web-backend/src/app/testing/provider-test.pem b/apps/web-backend/src/app/testing/provider-test.pem new file mode 100644 index 000000000..a995b89d8 --- /dev/null +++ b/apps/web-backend/src/app/testing/provider-test.pem @@ -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----- diff --git a/apps/web-backend/src/app/validated-http-client.integration.spec.ts b/apps/web-backend/src/app/validated-http-client.integration.spec.ts new file mode 100644 index 000000000..142257752 --- /dev/null +++ b/apps/web-backend/src/app/validated-http-client.integration.spec.ts @@ -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( + server: Server, + hostname: string, + run: (port: number) => Promise +): Promise { + await new Promise((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((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((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' + ); + }); + }); +}); diff --git a/apps/web-backend/src/app/validated-http-client.spec.ts b/apps/web-backend/src/app/validated-http-client.spec.ts new file mode 100644 index 000000000..475977323 --- /dev/null +++ b/apps/web-backend/src/app/validated-http-client.spec.ts @@ -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); + }); +}); diff --git a/apps/web-backend/src/app/validated-http-client.ts b/apps/web-backend/src/app/validated-http-client.ts new file mode 100644 index 000000000..7fc4f4de2 --- /dev/null +++ b/apps/web-backend/src/app/validated-http-client.ts @@ -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; + readonly params?: Record; + 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 { + 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( + url: string, + options?: ProviderTransportOptions + ): Promise>; +} + +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( + rawUrl: string, + options: WebBackendHttpGetOptions = {} + ): Promise> { + let currentUrl = rawUrl; + let params = options.params; + let headers = options.headers; + let initialResponded = false; + const visited = new Set(); + 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; + try { + response = await this.transport.get(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); + } + }; +} diff --git a/apps/web-backend/src/app/web-backend-app.host-guard.spec.ts b/apps/web-backend/src/app/web-backend-app.host-guard.spec.ts index b01b3529b..91b9743ca 100644 --- a/apps/web-backend/src/app/web-backend-app.host-guard.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.host-guard.spec.ts @@ -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((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; diff --git a/apps/web-backend/src/app/web-backend-app.redirects.spec.ts b/apps/web-backend/src/app/web-backend-app.redirects.spec.ts new file mode 100644 index 000000000..df2882bfd --- /dev/null +++ b/apps/web-backend/src/app/web-backend-app.redirects.spec.ts @@ -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( + 'Test' + ); + 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 }, + }); + } + ); + } + ); +}); diff --git a/apps/web-backend/src/app/web-backend-app.spec-helpers.ts b/apps/web-backend/src/app/web-backend-app.spec-helpers.ts index 2266cedaf..b4e882079 100644 --- a/apps/web-backend/src/app/web-backend-app.spec-helpers.ts +++ b/apps/web-backend/src/app/web-backend-app.spec-helpers.ts @@ -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( url: string, - options: WebBackendHttpGetOptions = {} - ): Promise<{ data: T }> { + options: ProviderTransportOptions = {} + ): Promise> { 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( const address = server.address() as AddressInfo; return await callback(`http://127.0.0.1:${address.port}`); } finally { + server.closeAllConnections(); await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts index 2a3080f82..f1086c944 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -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', diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index 4ff17922a..f7529eea0 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -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; - readonly params?: Record; - readonly responseType?: 'arraybuffer'; - readonly timeout?: number; -} - -export interface WebBackendHttpClient { - get( - 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; -} - -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; 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 { - 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 { - 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 { - 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 | PlaylistParseError> { try { - // Provider URLs are validated by /provider-targets before playlist parsing. - // codeql[js/request-forgery] const response = await options.httpClient.get(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 { const href = url.href; - // Provider URLs are validated by /provider-targets before XMLTV parsing. - // codeql[js/request-forgery] const response = await httpClient.get(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; -} diff --git a/apps/web-e2e/project.json b/apps/web-e2e/project.json index bba3cea4c..367cf8fb8 100644 --- a/apps/web-e2e/project.json +++ b/apps/web-e2e/project.json @@ -5,6 +5,7 @@ "sourceRoot": "apps/web-e2e/src", "implicitDependencies": [ "web", + "web-backend", "stalker-mock-server", "xtream-mock-server" ], diff --git a/docs/architecture/host-connectivity-guard.md b/docs/architecture/host-connectivity-guard.md index ecf2bd86b..956a8c5ce 100644 --- a/docs/architecture/host-connectivity-guard.md +++ b/docs/architecture/host-connectivity-guard.md @@ -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. diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index d55cd9356..5dbb8d670 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -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 diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index d95ee24ab..eed5d6c0b 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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`. From 5a8c5ca4a452f51aff924f403c1ee469a9ae0ad6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:05:21 +0200 Subject: [PATCH 2/4] fix(stalker): keep live search within the selected category (#1552) * fix(stalker): keep live search within the selected category * test(stalker): assert retained video ownership without source timing * test(stalker): distinguish paged All Items from the initial cache grid * fix(stalker): reveal remote selections in uncached search results * test(stalker): wait for category rows and retain settled playback --- .changes/stalker-category-search.md | 7 + AGENTS.md | 10 ++ CLAUDE.md | 10 ++ .../src/stalker-playback-headers.e2e.ts | 38 ++++- .../src/stalker-category-search.fixture.ts | 146 ++++++++++++++++++ apps/web-e2e/src/stalker.e2e.ts | 21 ++- docs/architecture/player-controls-contract.md | 10 +- docs/architecture/stalker-portal.md | 25 ++- .../with-stalker-content.feature.spec.ts | 124 +++++++++++++++ .../features/with-stalker-content.feature.ts | 37 ++++- .../with-stalker-selection.feature.spec.ts | 9 ++ .../with-stalker-selection.feature.ts | 6 +- .../panel-search-window.spec.ts | 4 + .../panel-search-window.ts | 15 +- ...alker-live-stream-layout.component.spec.ts | 17 +- .../stalker-live-stream-layout.component.ts | 79 ++++------ ...er-live-stream-layout.panel-search.spec.ts | 98 +++++++++++- 17 files changed, 559 insertions(+), 97 deletions(-) create mode 100644 .changes/stalker-category-search.md create mode 100644 apps/web-e2e/src/stalker-category-search.fixture.ts diff --git a/.changes/stalker-category-search.md b/.changes/stalker-category-search.md new file mode 100644 index 000000000..a0a52e132 --- /dev/null +++ b/.changes/stalker-category-search.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index fafb342ea..e00bab066 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,6 +240,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. +## 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 diff --git a/CLAUDE.md b/CLAUDE.md index b7af8b73f..c63701691 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1725,6 +1725,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. +## 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 diff --git a/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts b/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts index c8e7e8f1d..e787ce990 100644 --- a/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts +++ b/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts @@ -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(); diff --git a/apps/web-e2e/src/stalker-category-search.fixture.ts b/apps/web-e2e/src/stalker-category-search.fixture.ts new file mode 100644 index 000000000..166380797 --- /dev/null +++ b/apps/web-e2e/src/stalker-category-search.fixture.ts @@ -0,0 +1,146 @@ +import { expect, type Page } from '@playwright/test'; + +/** Category scope, independent fullscreen search and playback continuity (#1543). */ +export async function verifyStalkerCategorySearch(page: Page): Promise { + 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 { + 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 +): Promise { + 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()); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index cf7b71fae..2cc2c0609 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -1,5 +1,9 @@ import { type APIRequestContext, type Page } from '@playwright/test'; import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers'; +import { + verifyStalkerCategorySearch, + verifyUncachedStalkerSearch, +} from './stalker-category-search.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; import { expect, test } from './fixtures'; import { @@ -189,7 +193,9 @@ async function resetMockServer( for (let attempt = 0; attempt < 3; attempt += 1) { try { - const response = await request.post(`${MOCK_SERVER}/reset?${query}`); + const response = await request.post( + `${MOCK_SERVER}/reset?${query}` + ); if (response.ok()) { return; } @@ -701,7 +707,7 @@ 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 @@ -711,6 +717,7 @@ 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 @@ -733,6 +740,10 @@ 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() @@ -751,6 +762,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 ({ @@ -792,6 +804,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 ({ @@ -1183,9 +1196,7 @@ test('@stalker series watched toggle — embedded series marks and clears from t const menuTrigger = page.locator('[data-test-id="series-watch-menu"]'); await expect(menuTrigger).toBeVisible(); await menuTrigger.click(); - const seriesToggle = page.locator( - '[data-test-id="toggle-series-watched"]' - ); + const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]'); await expect(seriesToggle).toBeVisible(); await expect(seriesToggle).toContainText( `Mark series as watched (${episodeCount})` diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index ea178440f..abc177838 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index d8e193534..217e29466 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1324,20 +1324,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 diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index c9f01cc48..3a4d0a669 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -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>(); + 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(); + 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({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 8119854ae..f4e69d4f6 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -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 => { 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 diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts index 6e12451d9..9647fccdd 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts @@ -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', diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index ddcc1a3ee..e627ecac3 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -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 = diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts index 6e34a65a9..7ea2fd1b3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts @@ -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. diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts index a89e37510..9c83e333f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts @@ -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` diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 4d29b45d6..3070f72ac 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -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); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index e50410668..b6188c2cf 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -214,36 +214,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(); - 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) { @@ -291,7 +267,7 @@ export class StalkerLiveStreamLayoutComponent computation: () => FULL_LIST_RENDER_CHUNK, }); readonly visibleChannels = computed(() => - this.isCategoryFromCache() + this.isCategoryFromCache() || Boolean(this.searchTerm()) ? this.filteredChannels().slice(0, this.renderLimit()) : this.filteredChannels() ); @@ -323,10 +299,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); /** @@ -648,6 +624,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([]); @@ -1042,13 +1020,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; } @@ -1105,17 +1083,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); @@ -1132,7 +1113,7 @@ export class StalkerLiveStreamLayoutComponent this.growRenderWindow(); return; } - this.loadMore(); + this.loadNextChannelPage(); } private growRenderWindow(): void { @@ -1551,7 +1532,8 @@ export class StalkerLiveStreamLayoutComponent if ( !shouldAutoFillStampedList( isPanelContainer, - sidebarSearchActive + sidebarSearchActive, + !this.isRadioMode() ) ) { continue; @@ -1566,8 +1548,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 @@ -1587,7 +1569,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. diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts index e9ff6c2c3..2faea785c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts @@ -57,7 +57,7 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => { selectedItvId: signal(undefined), selectedItem: signal(null), itvChannels, - radioChannels: signal([]), + radioChannels: signal([]), 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: [ @@ -156,6 +159,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 @@ -304,16 +392,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 () => { From 61b06b9f31706e05c46a519a08f5f93cd8f15237 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:22:07 +0200 Subject: [PATCH 3/4] fix(portals): preserve live channel navigation while browsing (#1554) * fix(portals): preserve live channel navigation while browsing * test(portals): await media source assertion in remote E2E * fix(xtream): capture destination queue for live auto-open --- .changes/portals-live-channel-return.md | 7 + AGENTS.md | 10 + CLAUDE.md | 10 + .../src/remote-control.e2e.ts | 250 ++++++++++++- .../src/stalker-category-search.fixture.ts | 72 +++- apps/web-e2e/src/stalker.e2e.ts | 61 +--- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/hu.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/remote-control.md | 38 +- docs/architecture/stalker-portal.md | 7 +- docs/architecture/workspace-shell.md | 5 + .../plans/2026-09-06-live-channel-return.md | 62 ++++ libs/portal/shared/data-access/src/index.ts | 1 + .../lib/live-channel-playback-queue.spec.ts | 90 +++++ .../src/lib/live-channel-playback-queue.ts | 79 ++++ .../stalker-live-navigation.spec.ts | 273 ++++++++++++++ .../stalker-live-navigation.ts | 344 ++++++++++++++++++ .../stalker-live-stream-layout.component.html | 22 +- .../stalker-live-stream-layout.component.ts | 108 ++++-- ...r-live-stream-layout.remote-status.spec.ts | 63 +++- ...e-stream-auto-open-queue.component.spec.ts | 184 ++++++++++ .../live-stream-layout.component.html | 23 +- .../live-stream-layout.component.spec.ts | 29 +- .../live-stream-layout.component.ts | 54 +-- ...am-live-channel-navigation.service.spec.ts | 282 ++++++++++++++ .../xtream-live-channel-navigation.service.ts | 245 +++++++++++++ .../portal-channels-list.component.spec.ts | 35 ++ .../portal-channels-list.component.ts | 35 ++ 45 files changed, 2267 insertions(+), 141 deletions(-) create mode 100644 .changes/portals-live-channel-return.md create mode 100644 docs/superpowers/plans/2026-09-06-live-channel-return.md create mode 100644 libs/portal/shared/data-access/src/lib/live-channel-playback-queue.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/live-channel-playback-queue.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts diff --git a/.changes/portals-live-channel-return.md b/.changes/portals-live-channel-return.md new file mode 100644 index 000000000..a5e093a3e --- /dev/null +++ b/.changes/portals-live-channel-return.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index e00bab066..4177fc024 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,6 +240,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: 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 diff --git a/CLAUDE.md b/CLAUDE.md index c63701691..299ff6ab8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1725,6 +1725,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: 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 diff --git a/apps/electron-backend-e2e/src/remote-control.e2e.ts b/apps/electron-backend-e2e/src/remote-control.e2e.ts index 7fda35064..82d079cfe 100644 --- a/apps/electron-backend-e2e/src/remote-control.e2e.ts +++ b/apps/electron-backend-e2e/src/remote-control.e2e.ts @@ -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 = {} ): Promise { 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 { - 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 { diff --git a/apps/web-e2e/src/stalker-category-search.fixture.ts b/apps/web-e2e/src/stalker-category-search.fixture.ts index 166380797..1b085a068 100644 --- a/apps/web-e2e/src/stalker-category-search.fixture.ts +++ b/apps/web-e2e/src/stalker-category-search.fixture.ts @@ -56,7 +56,9 @@ export async function verifyStalkerCategorySearch(page: Page): Promise { expect(await playerNode?.evaluate((element) => element.isConnected)).toBe( true ); - expect(await videoNode?.evaluate((element) => element.isConnected)).toBe(true); + expect(await videoNode?.evaluate((element) => element.isConnected)).toBe( + true + ); await sidebarSearch.fill(''); await sidebarSearch.press('Enter'); @@ -144,3 +146,71 @@ async function verifyStalkerPanelCategory( 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 { + 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); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 2cc2c0609..651b43c25 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -2,6 +2,7 @@ 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'; @@ -467,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 ({ @@ -707,7 +664,9 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover await categories.nth(1).click(); - const channels = page.locator('#live-channels [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 @@ -717,7 +676,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(); + 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 @@ -742,7 +703,9 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover // 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); + await expect(channels.locator('.channel-name')).toHaveText( + firstCategoryNames + ); // Search a channel from deep in the list (beyond the first 14 items). const deepChannelName = ( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d427dd8a7..2c70c7777 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "إظهار القناة قيد التشغيل", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى", "ALL_CHANNELS": "جميع القنوات", "GROUPS": "المجموعات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 9b6069c89..07193fe59 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "بيّن القناة اللي خدامة", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى", "ALL_CHANNELS": "جميع القنوات", "GROUPS": "المجموعات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e650c255b..7dbd9e2cd 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Паказаць бягучы канал", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст", "ALL_CHANNELS": "Усе каналы", "GROUPS": "Групы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 2f47cc0fc..206ac9850 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)" }, "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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dac1fafda..3af3e99ac 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Εμφάνιση του καναλιού που παίζει", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής", "ALL_CHANNELS": "Όλα τα κανάλια", "GROUPS": "Ομάδες", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index e8efb2483..9497932b3 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3bda2b825..1801d2fa5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 46671d2a4..c8f823727 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 11b2a3065..178d6ee2b 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Csatornalista megjelenítése vagy elrejtése (⌘/Ctrl+B)" }, "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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index f3fa771c0..6da189902 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 225c96549..79be8eb91 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "再生中のチャンネルを表示", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択", "ALL_CHANNELS": "すべてのチャンネル", "GROUPS": "グループ", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0f67f9521..f99f0f9d8 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "재생 중인 채널 표시", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오", "ALL_CHANNELS": "모든 채널", "GROUPS": "그룹", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index beca4aa26..8179c2432 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e828573a6..fc4e60eeb 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index fba1440c3..8d8d06406 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c66fb1dda..6bf700511 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Показать текущий канал", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист", "ALL_CHANNELS": "Все каналы", "GROUPS": "Группы", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 8025e3aa3..daeb836e3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)" }, "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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0a95f2cc4..54d0dc95e 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "显示正在播放的频道", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表", "ALL_CHANNELS": "所有频道", "GROUPS": "分组", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 44089d006..1d91c7e24 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "顯示正在播放的頻道", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單", "ALL_CHANNELS": "所有頻道", "GROUPS": "群組", diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 2b9ff3322..ceaa8abda 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -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'` diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 217e29466..045caf399 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 63cc17262..11448b841 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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: diff --git a/docs/superpowers/plans/2026-09-06-live-channel-return.md b/docs/superpowers/plans/2026-09-06-live-channel-return.md new file mode 100644 index 000000000..924388147 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-live-channel-return.md @@ -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` 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. diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index 0887a2743..6f42a7274 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/collection'; export * from './lib/downloads'; export * from './lib/multi-source'; +export * from './lib/live-channel-playback-queue'; diff --git a/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.spec.ts b/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.spec.ts new file mode 100644 index 000000000..b3c7dd2e5 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.spec.ts @@ -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; + 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([]); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.ts b/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.ts new file mode 100644 index 000000000..f0f2f334a --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.ts @@ -0,0 +1,79 @@ +import { signal } from '@angular/core'; + +interface PlaybackQueueSnapshot { + readonly owner: string; + readonly scope: string; + readonly items: readonly T[]; +} + +/** Component-owned playback order, independent of the list being browsed. */ +export class LiveChannelPlaybackQueue { + private readonly snapshot = signal | 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 { + const result = new Map(); + for (const item of items) { + const id = this.key(item); + if (id && !result.has(id)) result.set(id, item); + } + return result; + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts new file mode 100644 index 000000000..2c1ab2dcc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts @@ -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('news'), + searchPhrase: signal(''), + selectedItem: signal(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((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'); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.ts new file mode 100644 index 000000000..ed1fc0fb3 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.ts @@ -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 | 'preserve' | undefined; +interface LiveNavigationOptions { + store: InstanceType; + router: Router | null; + sidebar: LiveLayoutSidebarStateService; + rows: (panelTerm?: Signal) => StalkerItvChannel[]; + play: (item: StalkerItvChannel) => void; + revealRow: (id: string) => boolean; + loading: () => boolean; +} +interface PlayingOrigin { + owner: string; + item: StalkerItvChannel; + categories: ReadonlyMap; + 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( + (item) => item.id + ); + private readonly origin = signal(null); + private readonly pendingReveal = signal(null); + private readonly revealed = signal<{ + owner: string; + category: string; + item: StalkerItvChannel; + } | null>(null); + private readonly panelTerm = signal | undefined>(undefined); + private ownerSeen = ''; + private generation = 0; + private revealGeneration = 0; + private revealTimer: ReturnType | 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 { + 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 { + 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); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 03c5fc220..99e945688 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -27,6 +27,19 @@ > } + @if (navigation.canReveal()) { + + } @if (isFullListMode()) { + } - + {{ zoomIcon() }} + } @if (showDateStepper()) {
@@ -145,7 +145,8 @@ } {{ - viewDate() | date: 'EEE, d MMM' : '' : currentLocale() + viewDate() + | date: 'EEE, d MMM' : '' : currentLocale() }}