refactor(portals): share the discover facet navigation across detail pages

The four render sites each carried their own copy of the year/genre/country
click handlers, which also pushed serial-details.component.ts past the
400-line limit. `createDiscoverFacetNavigation()` now owns the navigation,
the numeric-tmdb_id gate and the year parsing.

Year parsing moves from a fixed 4-char slice to the first four-digit run,
so a day-first provider date resolves instead of producing NaN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-16 08:15:04 +02:00
1 parent 486d04411f
commit ae01203dd0
11 files changed
+285 -159

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ export * from './lib/workspace-header-context.service';
export * from './lib/workspace-view-command.types';
export * from './lib/workspace-view-command.service';
export * from './lib/navigation/collection-detail-portal-navigation';
export * from './lib/navigation/discover-facet-navigation';
export * from './lib/navigation/discover-link.util';
export * from './lib/navigation/portal-rail-links';
export * from './lib/navigation/provider-detail-mode';
@@ -0,0 +1,126 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { Router } from '@angular/router';
import {
DiscoverFacetTarget,
createDiscoverFacetNavigation,
} from './discover-facet-navigation';
describe('createDiscoverFacetNavigation', () => {
let navigate: jest.Mock;
function create(target: () => DiscoverFacetTarget | null) {
const injector = Injector.create({
providers: [{ provide: Router, useValue: { navigate } }],
});
return runInInjectionContext(injector, () =>
createDiscoverFacetNavigation(target)
);
}
const xtreamMovie = (): DiscoverFacetTarget => ({
portal: 'xtream',
mediaType: 'movie',
playlistId: 'pl-1',
});
beforeEach(() => {
navigate = jest.fn();
});
it('navigates to the portal-scoped discover route for a genre', () => {
create(xtreamMovie).openGenre({ id: 18, name: 'Drama' });
expect(navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', 'pl-1', 'discover'],
{
queryParams: {
type: 'movie',
genre: '18',
genreLabel: 'Drama',
},
}
);
});
it('routes Stalker targets to the stalker segment and media type', () => {
create(() => ({
portal: 'stalker',
mediaType: 'tv',
playlistId: 'pl-2',
})).openCountry({ code: 'DE', name: 'Germany' });
expect(navigate).toHaveBeenCalledWith(
['/workspace', 'stalker', 'pl-2', 'discover'],
{
queryParams: {
type: 'tv',
country: 'DE',
countryLabel: 'Germany',
},
}
);
});
it('reads the year out of any provider date shape', () => {
const discover = create(xtreamMovie);
// Day-first dates are why this is a search, not a fixed slice
for (const [date, year] of [
['1976', '1976'],
['1999-03-31', '1999'],
['31-03-1999', '1999'],
]) {
navigate.mockClear();
discover.openYear(603, date);
expect(navigate).toHaveBeenCalledWith(expect.anything(), {
queryParams: { type: 'movie', year },
});
}
});
it('refuses the year facet without a merge-written numeric tmdb id', () => {
const discover = create(xtreamMovie);
// Providers ship tmdb_id as an untrusted string; only the merge
// writes a number, and only a matched item has a facet identity
expect(discover.canOpenYear('603', '1999-03-31')).toBe(false);
expect(discover.canOpenYear(undefined, '1999-03-31')).toBe(false);
expect(discover.canOpenYear(603, '1999-03-31')).toBe(true);
discover.openYear('603', '1999-03-31');
expect(navigate).not.toHaveBeenCalled();
});
it('refuses the year facet without a parsable year', () => {
const discover = create(xtreamMovie);
expect(discover.canOpenYear(603, '')).toBe(false);
expect(discover.canOpenYear(603, undefined)).toBe(false);
expect(discover.canOpenYear(603, 'unknown')).toBe(false);
discover.openYear(603, 'unknown');
expect(navigate).not.toHaveBeenCalled();
});
it('does not navigate while the host cannot resolve its playlist', () => {
const discover = create(() => null);
discover.openGenre({ id: 18, name: 'Drama' });
discover.openCountry({ code: 'US', name: 'United States' });
discover.openYear(603, '1999');
expect(navigate).not.toHaveBeenCalled();
});
it('re-reads the target on every click', () => {
let target: DiscoverFacetTarget | null = null;
const discover = create(() => target);
discover.openGenre({ id: 18, name: 'Drama' });
expect(navigate).not.toHaveBeenCalled();
target = xtreamMovie();
discover.openGenre({ id: 18, name: 'Drama' });
expect(navigate).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,94 @@
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import {
TmdbCountryFacet,
TmdbGenreFacet,
TmdbMediaType,
} from '@iptvnator/shared/interfaces';
import { discoverLink } from './discover-link.util';
/**
* Where a detail page's facet chips should navigate. `null` means the page
* cannot resolve its own playlist yet, so chips must not navigate at all.
*/
export interface DiscoverFacetTarget {
portal: 'xtream' | 'stalker';
mediaType: TmdbMediaType;
playlistId: string;
}
export interface DiscoverFacetNavigation {
canOpenYear(
tmdbId: unknown,
releaseDate: string | null | undefined
): boolean;
openYear(tmdbId: unknown, releaseDate: string | null | undefined): void;
openGenre(genre: TmdbGenreFacet): void;
openCountry(country: TmdbCountryFacet): void;
}
/**
* Year stated by a provider date field, whatever shape it arrives in —
* `1976`, `1999-03-31` and `31-03-1999` all resolve. Reads the first
* four-digit run rather than a fixed slice, which the day-first form
* would otherwise turn into `NaN`.
*/
function facetYear(releaseDate: string | null | undefined): number | null {
const match = releaseDate?.match(/\d{4}/);
return match ? Number(match[0]) : null;
}
/**
* Shared behavior behind the clickable year/genre/country chips on every
* detail page. Call it from a field initializer (injection context).
*
* The year gate is deliberately `typeof tmdbId === 'number'`: provider
* payloads ship `tmdb_id` as an untrusted string, so only a value written
* by the TMDB merge proves the item was actually matched — and without a
* match there is no facet identity to discover by.
*/
export function createDiscoverFacetNavigation(
target: () => DiscoverFacetTarget | null
): DiscoverFacetNavigation {
const router = inject(Router);
const navigate = (
facet: Parameters<typeof discoverLink>[3]
): void => {
const resolved = target();
if (!resolved) {
return;
}
const link = discoverLink(
resolved.portal,
resolved.playlistId,
resolved.mediaType,
facet
);
void router.navigate(link.commands, {
queryParams: link.queryParams,
});
};
const canOpenYear = (
tmdbId: unknown,
releaseDate: string | null | undefined
): boolean => typeof tmdbId === 'number' && facetYear(releaseDate) !== null;
return {
canOpenYear,
openYear(tmdbId, releaseDate) {
const year = facetYear(releaseDate);
if (year === null || !canOpenYear(tmdbId, releaseDate)) {
return;
}
navigate({ kind: 'year', year });
},
openGenre(genre) {
navigate({ kind: 'genre', genre });
},
openCountry(country) {
navigate({ kind: 'country', country });
},
};
}
@@ -19,9 +19,17 @@
<span
class="details__tag"
[class.details__tag--clickable]="
canDiscoverByYear(serial.info)
discover.canOpenYear(
serial.info.tmdb_id,
serial.info.releasedate
)
"
(click)="
discover.openYear(
serial.info.tmdb_id,
serial.info.releasedate
)
"
(click)="openYearDiscover(serial.info)"
>{{ serial.info.releasedate }}</span
>
}
@@ -29,7 +37,7 @@
@for (genre of serial.info.tmdb_genres; track genre.id) {
<span
class="details__tag details__tag--clickable"
(click)="openGenreDiscover(genre)"
(click)="discover.openGenre(genre)"
>{{ genre.name }}</span
>
}
@@ -53,7 +61,7 @@
) {
<span
class="details__tag details__tag--clickable"
(click)="openCountryDiscover(country)"
(click)="discover.openCountry(country)"
>{{ country.name }}</span
>
}
@@ -33,10 +33,7 @@ import {
PlaybackPositionData,
ResolvedPortalPlayback,
seriesStatusLabelKey,
StalkerVodInfo,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
XtreamSerieEpisode,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
@@ -49,8 +46,7 @@ import {
PORTAL_PLAYER,
createLogger,
consumeStalkerReturnMarker,
DiscoverFacetClick,
discoverLink,
createDiscoverFacetNavigation,
resolveStalkerBackNavigation,
} from '@iptvnator/portal/shared/util';
import {
@@ -879,37 +875,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
]);
}
/** Discover needs TMDB; only a matched series can offer facet chips */
canDiscoverByYear(info: StalkerVodInfo): boolean {
return typeof info.tmdb_id === 'number' && !!info.releasedate;
}
openYearDiscover(info: StalkerVodInfo): void {
const year = Number(info.releasedate?.match(/\d{4}/)?.[0]);
if (!this.canDiscoverByYear(info) || !Number.isInteger(year)) {
return;
}
this.openDiscoverFacet({ kind: 'year', year });
}
openGenreDiscover(genre: TmdbGenreFacet): void {
this.openDiscoverFacet({ kind: 'genre', genre });
}
openCountryDiscover(country: TmdbCountryFacet): void {
this.openDiscoverFacet({ kind: 'country', country });
}
private openDiscoverFacet(facet: DiscoverFacetClick): void {
/** Clickable year/genre/country chips (Discover pages) */
readonly discover = createDiscoverFacetNavigation(() => {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) {
return;
}
const link = discoverLink('stalker', playlistId, 'tv', facet);
void this.router.navigate(link.commands, {
queryParams: link.queryParams,
});
}
return playlistId
? { portal: 'stalker', mediaType: 'tv', playlistId }
: null;
});
goBack() {
const back = resolveStalkerBackNavigation(
@@ -19,8 +19,10 @@
@if (info.releaseDate) {
<span
class="details__tag"
[class.details__tag--clickable]="canDiscoverByYear(info)"
(click)="openYearDiscover(info)"
[class.details__tag--clickable]="
discover.canOpenYear(info.tmdb_id, info.releaseDate)
"
(click)="discover.openYear(info.tmdb_id, info.releaseDate)"
>{{ info.releaseDate | slice: 0 : 4 }}</span
>
}
@@ -28,7 +30,7 @@
@for (genre of info.tmdb_genres; track genre.id) {
<span
class="details__tag details__tag--clickable"
(click)="openGenreDiscover(genre)"
(click)="discover.openGenre(genre)"
>{{ genre.name }}</span
>
}
@@ -49,7 +51,7 @@
@for (country of info.tmdb_countries; track country.code) {
<span
class="details__tag details__tag--clickable"
(click)="openCountryDiscover(country)"
(click)="discover.openCountry(country)"
>{{ country.name }}</span
>
}
@@ -39,17 +39,14 @@ import {
import {
normalizeTitleKeys,
seriesStatusLabelKey,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
XtreamSerieDetails,
XtreamSerieEpisode,
XtreamSerieInfo,
} from '@iptvnator/shared/interfaces';
import { buildSeasonDescriptions } from './season-descriptions.util';
import {
DiscoverFacetClick,
discoverLink,
createDiscoverFacetNavigation,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
@@ -347,37 +344,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
]);
}
/** Discover needs TMDB; only a matched series can offer facet chips */
canDiscoverByYear(info: XtreamSerieInfo): boolean {
return typeof info.tmdb_id === 'number' && !!info.releaseDate;
}
openYearDiscover(info: XtreamSerieInfo): void {
const year = Number(info.releaseDate?.slice(0, 4));
if (!this.canDiscoverByYear(info) || !Number.isInteger(year)) {
return;
}
this.openDiscoverFacet({ kind: 'year', year });
}
openGenreDiscover(genre: TmdbGenreFacet): void {
this.openDiscoverFacet({ kind: 'genre', genre });
}
openCountryDiscover(country: TmdbCountryFacet): void {
this.openDiscoverFacet({ kind: 'country', country });
}
private openDiscoverFacet(facet: DiscoverFacetClick): void {
/** Clickable year/genre/country chips (Discover pages) */
readonly discover = createDiscoverFacetNavigation(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
const link = discoverLink('xtream', playlistId, 'tv', facet);
void this.router.navigate(link.commands, {
queryParams: link.queryParams,
});
}
return playlistId
? { portal: 'xtream', mediaType: 'tv', playlistId }
: null;
});
onSeasonSelected(seasonKey: string): void {
// The enrichment call itself runs from the constructor effect keyed
@@ -297,8 +297,10 @@
@if (info.releasedate) {
<span
class="details__tag"
[class.details__tag--clickable]="canDiscoverByYear(info)"
(click)="openYearDiscover(info)"
[class.details__tag--clickable]="
discover.canOpenYear(info.tmdb_id, info.releasedate)
"
(click)="discover.openYear(info.tmdb_id, info.releasedate)"
>{{ info.releasedate | slice: 0 : 4 }}</span
>
}
@@ -306,7 +308,7 @@
@for (genre of info.tmdb_genres; track genre.id) {
<span
class="details__tag details__tag--clickable"
(click)="openGenreDiscover(genre)"
(click)="discover.openGenre(genre)"
>{{ genre.name }}</span
>
}
@@ -325,7 +327,7 @@
@for (country of info.tmdb_countries; track country.code) {
<span
class="details__tag details__tag--clickable"
(click)="openCountryDiscover(country)"
(click)="discover.openCountry(country)"
>{{ country.name }}</span
>
}
@@ -27,9 +27,8 @@ import {
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import {
createDiscoverFacetNavigation,
createLogger,
DiscoverFacetClick,
discoverLink,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
@@ -52,9 +51,7 @@ import {
normalizeTitleKeys,
playlistDisplayLabel,
reportsPlaybackFailures,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
XtreamCategory,
XtreamVodDetails,
XtreamVodInfo,
@@ -491,37 +488,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
]);
}
/** Discover needs TMDB; only a matched item can offer facet chips */
canDiscoverByYear(info: XtreamVodInfo): boolean {
return typeof info.tmdb_id === 'number' && !!info.releasedate;
}
openYearDiscover(info: XtreamVodInfo): void {
const year = Number(info.releasedate?.slice(0, 4));
if (!this.canDiscoverByYear(info) || !Number.isInteger(year)) {
return;
}
this.openDiscoverFacet({ kind: 'year', year });
}
openGenreDiscover(genre: TmdbGenreFacet): void {
this.openDiscoverFacet({ kind: 'genre', genre });
}
openCountryDiscover(country: TmdbCountryFacet): void {
this.openDiscoverFacet({ kind: 'country', country });
}
private openDiscoverFacet(facet: DiscoverFacetClick): void {
/** Clickable year/genre/country chips (Discover pages) */
readonly discover = createDiscoverFacetNavigation(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
const link = discoverLink('xtream', playlistId, 'movie', facet);
void this.router.navigate(link.commands, {
queryParams: link.queryParams,
});
}
return playlistId
? { portal: 'xtream', mediaType: 'movie', playlistId }
: null;
});
ngOnDestroy(): void {
if (this.favoritePulseTimer) {
@@ -14,8 +14,10 @@
@if (meta.year) {
<span
class="details__tag"
[class.details__tag--clickable]="canDiscoverByYear()"
(click)="openYearDiscover()"
[class.details__tag--clickable]="
discover.canOpenYear(meta.tmdbId, meta.year)
"
(click)="discover.openYear(meta.tmdbId, meta.year)"
>{{ meta.year }}</span
>
}
@@ -23,7 +25,7 @@
@for (genre of meta.tmdbGenres; track genre.id) {
<span
class="details__tag details__tag--clickable"
(click)="openGenreDiscover(genre)"
(click)="discover.openGenre(genre)"
>{{ genre.name }}</span
>
}
@@ -42,7 +44,7 @@
@for (country of meta.tmdbCountries; track country.code) {
<span
class="details__tag details__tag--clickable"
(click)="openCountryDiscover(country)"
(click)="discover.openCountry(country)"
>{{ country.name }}</span
>
}
@@ -12,10 +12,9 @@ import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { SafePipe } from '@iptvnator/pipes';
import {
DiscoverFacetClick,
PORTAL_EXTERNAL_PLAYBACK,
createDiscoverFacetNavigation,
createExternalPlaybackButtonState,
discoverLink,
} from '@iptvnator/portal/shared/util';
import {
DetailActionsTemplateDirective,
@@ -28,9 +27,7 @@ import { Router } from '@angular/router';
import {
ExternalPlayerSession,
ResolvedPortalPlayback,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
VodDetailsItem,
getVodNumericId,
normalizeVodDetails,
@@ -323,43 +320,19 @@ export class VodDetailsComponent {
this.backClicked.emit();
}
// ============ Discover (clickable metadata chips) ============
/** Discover needs TMDB; only a matched item can offer facet chips */
readonly canDiscoverByYear = computed(
() =>
this.normalizedMeta().tmdbId !== undefined &&
!!this.normalizedMeta().year
);
openYearDiscover(): void {
const year = Number(this.normalizedMeta().year);
if (!this.canDiscoverByYear() || !Number.isInteger(year)) {
return;
}
this.openDiscover({ kind: 'year', year });
}
openGenreDiscover(genre: TmdbGenreFacet): void {
this.openDiscover({ kind: 'genre', genre });
}
openCountryDiscover(country: TmdbCountryFacet): void {
this.openDiscover({ kind: 'country', country });
}
private openDiscover(facet: DiscoverFacetClick): void {
/** Clickable year/genre/country chips (Discover pages) */
readonly discover = createDiscoverFacetNavigation(() => {
const item = this.item();
const link = discoverLink(
item.type === 'stalker' ? 'stalker' : 'xtream',
item.playlistId,
this.normalizedMeta().tmdbMediaType ?? 'movie',
facet
);
void this.router.navigate(link.commands, {
queryParams: link.queryParams,
});
}
return item.playlistId
? {
portal: item.type === 'stalker' ? 'stalker' : 'xtream',
// Stalker embedded-VOD series render here but are matched
// as tv, so the merge's verdict decides — not the route
mediaType: this.normalizedMeta().tmdbMediaType ?? 'movie',
playlistId: item.playlistId,
}
: null;
});
/** Handle download request */
onDownload(): void {