From 60be8a299973c08319ebb6652347014f72947ca4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 14:31:10 +0200 Subject: [PATCH] fix(tmdb): provide route params observable to inline collection details, linearize regexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global-collection inline detail host builds a fake ActivatedRoute for VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params. Since the detail components now read route.params via toSignal() (detail-> detail re-init), the missing observable crashed component construction and the content hero never rendered — broke dashboard-activation, favorites and recent Electron E2E on all platforms. Provide the params observable alongside the snapshot and assert it in the component spec. Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in normalizeTitle now excludes opening delimiters inside the classes, and youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u escapes (greptile note). Co-Authored-By: Claude Fable 5 --- ...xtream-collection-detail.component.spec.ts | 20 ++++++++---- .../lib/xtream-collection-detail.component.ts | 32 +++++++++---------- .../src/lib/title-normalization.util.ts | 6 ++-- .../src/lib/vod-details-adapters.ts | 16 +++++++--- 4 files changed, 43 insertions(+), 31 deletions(-) diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts index 30c96bccc..2869f20d9 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts @@ -8,7 +8,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { PlaylistsService } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { SerialDetailsComponent } from './serial-details/serial-details.component'; import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component'; @@ -129,12 +129,18 @@ describe('XtreamCollectionDetailComponent', () => { expect(fixture.componentInstance.detailComponent()).toBe( SerialDetailsComponent ); - expect( - fixture.componentInstance - .detailInjector() - ?.get(ActivatedRoute) - .snapshot.params - ).toEqual({ + const route = fixture.componentInstance + .detailInjector() + ?.get(ActivatedRoute); + expect(route?.snapshot.params).toEqual({ + categoryId: '3', + serialId: '103', + }); + // Regression: the detail components consume route.params via + // toSignal(), so the fake route must expose the observable too — + // otherwise the inline detail crashes on construction. + expect(route?.params).toBeDefined(); + await expect(firstValueFrom(route!.params)).resolves.toEqual({ categoryId: '3', serialId: '103', }); diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts index 737acfa7c..b6e56054e 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts @@ -19,7 +19,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { PlaylistsService } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { SerialDetailsComponent } from './serial-details/serial-details.component'; import { VodDetailsRouteComponent } from './vod-details/vod-details-route.component'; @@ -132,28 +132,26 @@ export class XtreamCollectionDetailComponent { ? VodDetailsRouteComponent : SerialDetailsComponent ); + const routeParams = + item.contentType === 'movie' + ? { + categoryId: this.toPathSegment(item.categoryId), + vodId: xtreamId, + } + : { + categoryId: this.toPathSegment(item.categoryId), + serialId: xtreamId, + }; this.detailInjector.set( Injector.create({ providers: [ { provide: ActivatedRoute, + // The detail components read both snapshot.params and + // the params observable (via toSignal) — provide both. useValue: { - snapshot: { - params: - item.contentType === 'movie' - ? { - categoryId: this.toPathSegment( - item.categoryId - ), - vodId: xtreamId, - } - : { - categoryId: this.toPathSegment( - item.categoryId - ), - serialId: xtreamId, - }, - }, + snapshot: { params: routeParams }, + params: of(routeParams), }, }, ], diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.ts b/libs/shared/interfaces/src/lib/title-normalization.util.ts index e5c449c75..c7629fc95 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.ts @@ -46,10 +46,12 @@ export function normalizeTitle(raw: string | null | undefined): string { } const cleaned = raw - .replace(/\[[^\]]*\]|\([^)]*\)|\{[^}]*\}/g, ' ') + // Inner classes exclude the opening delimiter too, so runaway + // inputs like "[[[[[…" backtrack linearly (CodeQL js/polynomial-redos) + .replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ') .replace(LANGUAGE_PREFIX, '') .normalize('NFD') - .replace(/[̀-ͯ]/g, '') + .replace(/[\u0300-\u036F]/g, '') .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, ' ') .split(' ') diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.ts index 6fd7b4d67..c94d86a78 100644 --- a/libs/shared/interfaces/src/lib/vod-details-adapters.ts +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.ts @@ -76,12 +76,18 @@ export function youtubeEmbedUrl( return null; } + // Two linear passes instead of one "watch\?.*v=" alternation, which + // backtracks polynomially on hostile input (CodeQL js/polynomial-redos) let videoId = raw; - const urlMatch = raw.match( - /(?:youtube(?:-nocookie)?\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([A-Za-z0-9_-]{6,})/ - ); - if (urlMatch) { - videoId = urlMatch[1]; + if (/youtube(?:-nocookie)?\.com\/watch\?/.test(raw)) { + videoId = raw.match(/[?&]v=([A-Za-z0-9_-]{6,})/)?.[1] ?? ''; + } else { + const urlMatch = raw.match( + /(?:youtube(?:-nocookie)?\.com\/(?:embed|shorts)\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/ + ); + if (urlMatch) { + videoId = urlMatch[1]; + } } return /^[A-Za-z0-9_-]{6,}$/.test(videoId)