From ae01203dd0cc8b7de5521e1a3f77acbd2bbf17f2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Aug 2026 07:45:46 +0200 Subject: [PATCH] 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 --- libs/portal/shared/util/src/index.ts | 1 + .../discover-facet-navigation.spec.ts | 126 ++++++++++++++++++ .../navigation/discover-facet-navigation.ts | 94 +++++++++++++ .../stalker-series-view.component.html | 16 ++- .../stalker-series-view.component.ts | 42 +----- .../serial-details.component.html | 10 +- .../serial-details.component.ts | 41 +----- .../vod-details-route.component.html | 10 +- .../vod-details-route.component.ts | 41 +----- .../vod-details/vod-details.component.html | 10 +- .../lib/vod-details/vod-details.component.ts | 53 ++------ 11 files changed, 285 insertions(+), 159 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.spec.ts create mode 100644 libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.ts diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index b8283af84..adcf5eec4 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.spec.ts new file mode 100644 index 000000000..00efb9b3a --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.spec.ts @@ -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); + }); +}); diff --git a/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.ts b/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.ts new file mode 100644 index 000000000..12fc80970 --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.ts @@ -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[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 }); + }, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index aeaedd796..c6f16718c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -19,9 +19,17 @@ {{ serial.info.releasedate }} } @@ -29,7 +37,7 @@ @for (genre of serial.info.tmdb_genres; track genre.id) { {{ genre.name }} } @@ -53,7 +61,7 @@ ) { {{ country.name }} } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 2478657db..1dc57913d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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( diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 88e12e24a..0dde0ab5a 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -19,8 +19,10 @@ @if (info.releaseDate) { {{ info.releaseDate | slice: 0 : 4 }} } @@ -28,7 +30,7 @@ @for (genre of info.tmdb_genres; track genre.id) { {{ genre.name }} } @@ -49,7 +51,7 @@ @for (country of info.tmdb_countries; track country.code) { {{ country.name }} } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 714883f1b..31fc3e1bc 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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 diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 51a25f88e..53702cdf0 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -297,8 +297,10 @@ @if (info.releasedate) { {{ info.releasedate | slice: 0 : 4 }} } @@ -306,7 +308,7 @@ @for (genre of info.tmdb_genres; track genre.id) { {{ genre.name }} } @@ -325,7 +327,7 @@ @for (country of info.tmdb_countries; track country.code) { {{ country.name }} } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index de7854809..ff308254d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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) { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index 203e0545f..0276ff0ad 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -14,8 +14,10 @@ @if (meta.year) { {{ meta.year }} } @@ -23,7 +25,7 @@ @for (genre of meta.tmdbGenres; track genre.id) { {{ genre.name }} } @@ -42,7 +44,7 @@ @for (country of meta.tmdbCountries; track country.code) { {{ country.name }} } diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 10ace5f6f..065782afd 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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 {