diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts new file mode 100644 index 000000000..eacfe5799 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts @@ -0,0 +1,99 @@ +import { + Injectable, + Signal, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarItem, + CrossPortalSimilarService, +} from '@iptvnator/services'; +import { normalizeTitleKeys } from '@iptvnator/shared/interfaces'; +import { + SimilarCatalogItem, + matchRecommendationsToCatalog, +} from '../tmdb-similar.util'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +/** + * The "Similar" rail on the series details page — the series counterpart of + * {@link VodDetailsSimilarService}, with the same two sources: TMDB + * recommendations matched against this playlist's loaded catalog, and — in + * Electron — the same recommendations matched across every OTHER imported + * playlist, filtered against the first list so one show cannot appear twice. + * + * Component-provided: the cross-portal lookup is per-series and must die with + * the page rather than leak into the next show. + */ +@Injectable() +export class SerialDetailsSimilarService { + private readonly xtreamStore = inject(XtreamStore); + private readonly crossPortalSimilar = inject(CrossPortalSimilarService); + + private selectedItem: Signal = signal(null); + + bind(bindings: { + selectedItem: Signal; + }): void { + this.selectedItem = bindings.selectedItem; + } + + readonly similarItems = computed(() => { + const item = this.selectedItem(); + const recommendations = item?.info?.tmdb_recommendations; + if (!recommendations?.length) { + return []; + } + return matchRecommendationsToCatalog( + recommendations, + this.xtreamStore.serialStreams(), + { excludeId: Number(item?.series_id) } + ); + }); + + /** Recommendations found in the user's OTHER portals (Electron only) */ + private readonly crossPortalItems = signal([]); + + readonly similarInPortals = computed(() => { + const localTitles = new Set( + this.similarItems().map( + (item) => normalizeTitleKeys(item.title).exact + ) + ); + return this.crossPortalItems().filter( + (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) + ); + }); + + private readonly loadCrossPortalSimilar = effect(() => { + const recommendations = this.selectedItem()?.info?.tmdb_recommendations; + const playlistId = this.xtreamStore.currentPlaylist()?.id; + untracked(() => { + this.crossPortalItems.set([]); + if ( + !recommendations?.length || + !this.crossPortalSimilar.isAvailable + ) { + return; + } + void this.crossPortalSimilar + .matchRecommendations(recommendations, 'series', { + excludePlaylistId: playlistId, + }) + .then((items) => { + // Staleness guard: a slow lookup for the previous series + // must not populate the rail of the one now on screen + if ( + this.selectedItem()?.info?.tmdb_recommendations === + recommendations + ) { + this.crossPortalItems.set(items); + } + }); + }); + }); +} 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 31fc3e1bc..32fb4f248 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 @@ -37,7 +37,6 @@ import { type UpNextRailItem, } from '@iptvnator/ui/playback'; import { - normalizeTitleKeys, seriesStatusLabelKey, TmdbEnrichedCastMember, XtreamSerieDetails, @@ -58,10 +57,8 @@ import { type XtreamSerieDetailsView, } from './serial-details-playback.service'; import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service'; -import { - SimilarCatalogItem, - matchRecommendationsToCatalog, -} from '../tmdb-similar.util'; +import { SerialDetailsSimilarService } from './serial-details-similar.service'; +import { SimilarCatalogItem } from '../tmdb-similar.util'; import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata'; import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter'; import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; @@ -82,7 +79,11 @@ import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; } `, ], - providers: [SerialDetailsPlaybackService, SerialDetailsSeasonWatchService], + providers: [ + SerialDetailsPlaybackService, + SerialDetailsSeasonWatchService, + SerialDetailsSimilarService, + ], imports: [ DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -189,61 +190,14 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { buildSeasonDescriptions(this.selectedItem()) ); - /** TMDB recommendations matched against the loaded series catalog */ - readonly similarItems = computed(() => { - const item = this.selectedItem(); - const recommendations = item?.info?.tmdb_recommendations; - if (!recommendations?.length) { - return []; - } - return matchRecommendationsToCatalog( - recommendations, - this.xtreamStore.serialStreams(), - { excludeId: Number(item?.series_id) } - ); - }); - - /** Recommendations found in the user's OTHER portals (Electron only) */ - private readonly crossPortalItems = signal([]); - readonly similarInPortals = computed(() => { - const localTitles = new Set( - this.similarItems().map( - (item) => normalizeTitleKeys(item.title).exact - ) - ); - return this.crossPortalItems().filter( - (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) - ); - }); - - private readonly loadCrossPortalSimilar = effect(() => { - const recommendations = this.selectedItem()?.info?.tmdb_recommendations; - const playlistId = this.xtreamStore.currentPlaylist()?.id; - untracked(() => { - this.crossPortalItems.set([]); - if ( - !recommendations?.length || - !this.crossPortalSimilar.isAvailable - ) { - return; - } - void this.crossPortalSimilar - .matchRecommendations(recommendations, 'series', { - excludePlaylistId: playlistId, - }) - .then((items) => { - if ( - this.selectedItem()?.info?.tmdb_recommendations === - recommendations - ) { - this.crossPortalItems.set(items); - } - }); - }); - }); + /** The "Similar" rail: catalog matches plus cross-portal matches */ + private readonly similar = inject(SerialDetailsSimilarService); + readonly similarItems = this.similar.similarItems; + readonly similarInPortals = this.similar.similarInPortals; constructor() { this.playback.bind({ selectedItem: this.selectedItem }); + this.similar.bind({ selectedItem: this.selectedItem }); // TMDB season enrichment, keyed on (tmdb_id, selected season). With // season tabs the first seasonSelected fires as soon as seasons load —