fix(tmdb): provide route params observable to inline collection details, linearize regexes

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 14:31:10 +02:00
1 parent 600685d897
commit 60be8a2999
4 files changed
+43 -31

No files matched your search

@@ -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',
});
@@ -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),
},
},
],
@@ -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(' ')
@@ -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)