diff --git a/CLAUDE.md b/CLAUDE.md index c665607a9..a18670bde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -645,7 +645,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use **TMDB Metadata Enrichment** (opt-in): - Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works -- "Similar" rail in Xtream detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation +- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation - Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`) - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index e075c136d..9265d9277 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -124,9 +124,17 @@ year tag is compatible (±1) with the TMDB year, so "Blade Runner" (1982) never claims a catalog "Blade Runner 2049". The rail navigates to the matched item — the detail components re-initialize on route param changes (reactive `routeParams` signal) because the router reuses the -component for detail→detail navigation. Stalker gets trailers and the -`tmdb_recommendations` data, but no rail yet (its catalog is -server-paginated, so there is no local list to match against). +component for detail→detail navigation. + +`CrossPortalSimilarService` (`libs/services`) extends the rail across +portals: recommendations are matched against ALL imported Xtream +playlists with one batched `DB_MATCH_TITLES` request (Electron only, +same two-tier + year rule). Stalker detail views — where the local +catalog is server-paginated and unmatchable — get their "Similar" rail +purely from these cross-portal matches (shared `VodDetailsComponent` for +movies, `stalker-series-view` for series); Xtream detail views append +them after the local-catalog matches, deduplicated by normalized title, +with the source playlist name on each card. The `language` param derives from the app language setting (`Language` enum → TMDB code, e.g. `de` → `de-DE`); cache rows are keyed per language, so switching the app language re-fetches localized metadata. 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 b2f1c99d7..3b90c38b0 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 @@ -166,6 +166,53 @@ }
+ @if (similarInPortals().length) { +
+

{{ 'XTREAM.SIMILAR' | translate }}

+
+ @for ( + item of similarInPortals(); + track item.match.xtreamId + ) { + + } +
+
+ } + @if (trailerEmbedUrl(); as trailerUrl) {
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 7d26a7f7c..5e7cb5da8 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 @@ -62,6 +62,8 @@ import { type SeriesPlaybackEpisodeState, } from '@iptvnator/ui/playback'; import { + CrossPortalSimilarItem, + CrossPortalSimilarService, DownloadsService, PlaybackPositionRuntimeBridgeService, } from '@iptvnator/services'; @@ -128,6 +130,38 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly selectedItem = this.stalkerStore.selectedItem; private readonly tmdbSeasons = inject(StalkerSeriesTmdbSeasonsService); + private readonly crossPortalSimilar = inject(CrossPortalSimilarService); + + /** + * TMDB recommendations found in the user's Xtream portals (batched DB + * match, Electron only) — Stalker catalogs are server-paginated, so + * "Similar" can only point at OTHER portals' libraries. + */ + readonly similarInPortals = signal([]); + private readonly loadSimilarInPortals = effect(() => { + const recommendations = + this.displayItem()?.info?.tmdb_recommendations; + untracked(() => { + this.similarInPortals.set([]); + if ( + !recommendations?.length || + !this.crossPortalSimilar.isAvailable + ) { + return; + } + void this.crossPortalSimilar + .matchRecommendations(recommendations, 'series') + .then((items) => { + if ( + this.displayItem()?.info?.tmdb_recommendations === + recommendations + ) { + this.similarInPortals.set(items); + } + }); + }); + }); + /** Season currently selected in the season container. */ private readonly selectedSeasonKey = signal(null); @@ -531,6 +565,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } + openSimilarInPortals(item: CrossPortalSimilarItem): void { + void this.router.navigate(this.crossPortalSimilar.buildLink(item)); + } + openActor(member: TmdbEnrichedCastMember): void { const playlistId = this.stalkerStore.currentPlaylist()?._id; if (!playlistId || !member.tmdbPersonId) { 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 d7bfb3592..a46abaf26 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 @@ -165,7 +165,7 @@ />
- @if (similarItems().length) { + @if (similarItems().length || similarInPortals().length) {

{{ 'XTREAM.SIMILAR' | translate }}

@@ -195,6 +195,42 @@ } + @for ( + item of similarInPortals(); + track 'x' + item.match.playlistId + '-' + item.match.xtreamId + ) { + + }
} 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 1c55380c4..d84a83be2 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 @@ -33,7 +33,12 @@ import { XtreamSerieInfo, XtreamSerieEpisode, XtreamSerieDetails, + normalizeTitleKeys, } from '@iptvnator/shared/interfaces'; +import { + CrossPortalSimilarItem, + CrossPortalSimilarService, +} from '@iptvnator/services'; import { SerialDetailsPlaybackService, type XtreamSerieDetailsView, @@ -76,6 +81,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { private readonly location = inject(Location); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly crossPortalSimilar = inject(CrossPortalSimilarService); private readonly xtreamStore = inject(XtreamStore); private readonly playback = inject(SerialDetailsPlaybackService); private readonly snackBar = inject(MatSnackBar); @@ -138,6 +144,46 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { ); }); + /** 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); + } + }); + }); + }); + constructor() { this.playback.bind({ selectedItem: this.selectedItem }); @@ -246,6 +292,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { this.xtreamStore.setSelectedItem(null); } + openSimilarInPortals(item: CrossPortalSimilarItem): void { + void this.router.navigate(this.crossPortalSimilar.buildLink(item)); + } + openSimilar(item: SimilarCatalogItem): void { void this.router.navigate(['../..', item.categoryId, item.id], { relativeTo: this.route, 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 c95b2a366..1a5494699 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 @@ -237,7 +237,7 @@
} - @if (similarItems().length) { + @if (similarItems().length || similarInPortals().length) {

{{ 'XTREAM.SIMILAR' | translate }}

@@ -267,6 +267,42 @@ } + @for ( + item of similarInPortals(); + track 'x' + item.match.playlistId + '-' + item.match.xtreamId + ) { + + }
} 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 463023e0c..ac33511eb 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 @@ -8,6 +8,7 @@ import { effect, inject, signal, + untracked, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatIcon } from '@angular/material/icon'; @@ -27,14 +28,20 @@ import { type PlaybackFallbackRequest, PortalInlinePlayerComponent, } from '@iptvnator/ui/playback'; -import { DownloadsService, SettingsStore } from '@iptvnator/services'; import { - XtreamCategory, + CrossPortalSimilarItem, + CrossPortalSimilarService, + DownloadsService, + SettingsStore, +} from '@iptvnator/services'; +import { + getXtreamVodInfo, + normalizeTitleKeys, TmdbEnrichedCastMember, + XtreamCategory, XtreamVodDetails, XtreamVodInfo, XtreamVodStream, - getXtreamVodInfo, youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; import { @@ -72,6 +79,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private readonly settingsStore = inject(SettingsStore); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly crossPortalSimilar = inject(CrossPortalSimilarService); private readonly xtreamStore = inject(XtreamStore); private readonly downloadsService = inject(DownloadsService); private readonly snackBar = inject(MatSnackBar); @@ -228,6 +236,45 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { ); }); + /** 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.selectedVodInfo()?.tmdb_recommendations; + const playlistId = this.xtreamStore.currentPlaylist()?.id; + untracked(() => { + this.crossPortalItems.set([]); + if ( + !recommendations?.length || + !this.crossPortalSimilar.isAvailable + ) { + return; + } + void this.crossPortalSimilar + .matchRecommendations(recommendations, 'movie', { + excludePlaylistId: playlistId, + }) + .then((items) => { + if ( + this.selectedVodInfo()?.tmdb_recommendations === + recommendations + ) { + this.crossPortalItems.set(items); + } + }); + }); + }); + constructor() { this.playback.bind({ vodId: this.selectedVodId, @@ -288,6 +335,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } } + openSimilarInPortals(item: CrossPortalSimilarItem): void { + void this.router.navigate(this.crossPortalSimilar.buildLink(item)); + } + openSimilar(item: SimilarCatalogItem): void { void this.router.navigate(['../..', item.categoryId, item.id], { relativeTo: this.route, diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index d5756089e..48a487587 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/catalog-title-match.service'; +export * from './lib/cross-portal-similar.service'; export * from './lib/data.service'; export * from './lib/database-electron.service'; export * from './lib/downloads.service'; diff --git a/libs/services/src/lib/cross-portal-similar.service.spec.ts b/libs/services/src/lib/cross-portal-similar.service.spec.ts new file mode 100644 index 000000000..40d345592 --- /dev/null +++ b/libs/services/src/lib/cross-portal-similar.service.spec.ts @@ -0,0 +1,110 @@ +import { TestBed } from '@angular/core/testing'; +import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; +import { CatalogTitleMatchService } from './catalog-title-match.service'; +import { CrossPortalSimilarService } from './cross-portal-similar.service'; + +describe('CrossPortalSimilarService', () => { + const rec = (title: string, year: number | null = 1999) => ({ + tmdbId: 1, + title, + year, + posterUrl: null, + }); + + const match = ( + overrides: Partial = {} + ): CatalogTitleMatch => ({ + queryTitle: 'The Matrix', + playlistId: 'pl-1', + playlistName: 'Portal One', + categoryId: 7, + xtreamId: 42, + type: 'movie', + trailingYear: null, + ...overrides, + }); + + let matchTitles: jest.Mock; + let isAvailable: boolean; + + function createService(): CrossPortalSimilarService { + TestBed.configureTestingModule({ + providers: [ + { + provide: CatalogTitleMatchService, + useValue: { + get isAvailable() { + return isAvailable; + }, + matchTitles, + }, + }, + ], + }); + return TestBed.inject(CrossPortalSimilarService); + } + + beforeEach(() => { + isAvailable = true; + matchTitles = jest.fn().mockResolvedValue([match()]); + }); + + it('resolves to [] in the PWA', async () => { + isAvailable = false; + const service = createService(); + await expect( + service.matchRecommendations([rec('The Matrix')], 'movie') + ).resolves.toEqual([]); + expect(matchTitles).not.toHaveBeenCalled(); + }); + + it('returns matched recommendations with navigation targets', async () => { + const service = createService(); + const items = await service.matchRecommendations( + [rec('The Matrix'), rec('Unmatched Title')], + 'movie' + ); + + expect(items).toHaveLength(1); + expect(items[0].match.playlistName).toBe('Portal One'); + expect(service.buildLink(items[0])).toEqual([ + '/workspace/xtreams', + 'pl-1', + 'vod', + '7', + '42', + ]); + }); + + it('excludes the current playlist but keeps other playlists', async () => { + matchTitles.mockResolvedValue([ + match({ playlistId: 'current' }), + match({ playlistId: 'other', playlistName: 'Portal Two' }), + ]); + const service = createService(); + + const items = await service.matchRecommendations( + [rec('The Matrix')], + 'movie', + { excludePlaylistId: 'current' } + ); + + expect(items).toHaveLength(1); + expect(items[0].match.playlistId).toBe('other'); + }); + + it('drops type mismatches and year-incompatible matches', async () => { + matchTitles.mockResolvedValue([ + match({ type: 'series' }), + match({ queryTitle: 'Blade Runner', trailingYear: 2049 }), + ]); + const service = createService(); + + const items = await service.matchRecommendations( + [rec('The Matrix'), rec('Blade Runner', 1982)], + 'movie' + ); + + expect(items).toEqual([]); + }); +}); diff --git a/libs/services/src/lib/cross-portal-similar.service.ts b/libs/services/src/lib/cross-portal-similar.service.ts new file mode 100644 index 000000000..73081bc83 --- /dev/null +++ b/libs/services/src/lib/cross-portal-similar.service.ts @@ -0,0 +1,101 @@ +import { Injectable, inject } from '@angular/core'; +import { + CatalogTitleMatch, + TmdbRecommendation, + normalizeTitleKeys, + titleYearsCompatible, +} from '@iptvnator/shared/interfaces'; +import { + CatalogTitleMatchService, + buildTitleMatchIndex, +} from './catalog-title-match.service'; + +/** One TMDB recommendation found in an imported Xtream playlist */ +export interface CrossPortalSimilarItem { + title: string; + posterUrl: string | null; + year: number | null; + /** Where to navigate: playlist + category + item in that portal */ + match: CatalogTitleMatch; +} + +const DEFAULT_LIMIT = 12; + +/** + * Matches TMDB recommendations against ALL imported Xtream playlists via + * one batched DB-worker request — powers the "Similar" rail for portals + * without a matchable local catalog (Stalker) and supplements the Xtream + * rail with titles available in the user's other portals. Electron-only: + * `isAvailable` is false in the PWA and every call resolves to []. + */ +@Injectable({ providedIn: 'root' }) +export class CrossPortalSimilarService { + private readonly titleMatch = inject(CatalogTitleMatchService); + + get isAvailable(): boolean { + return this.titleMatch.isAvailable; + } + + async matchRecommendations( + recommendations: readonly TmdbRecommendation[] | undefined, + type: 'movie' | 'series', + options: { excludePlaylistId?: string; limit?: number } = {} + ): Promise { + if (!recommendations?.length || !this.isAvailable) { + return []; + } + + const matches = await this.titleMatch.matchTitles( + recommendations.map((recommendation) => recommendation.title) + ); + // Filter before indexing so a title also present in another + // playlist survives the exclusion of the current one + const index = buildTitleMatchIndex( + matches.filter( + (match) => + match.type === type && + match.playlistId !== options.excludePlaylistId + ) + ); + + const limit = options.limit ?? DEFAULT_LIMIT; + const seen = new Set(); + const items: CrossPortalSimilarItem[] = []; + for (const recommendation of recommendations) { + if (items.length >= limit) { + break; + } + const key = `${type}:${normalizeTitleKeys(recommendation.title).exact}`; + const match = index.get(key); + if ( + !match || + !titleYearsCompatible(recommendation.year, match.trailingYear) + ) { + continue; + } + const dedupeKey = `${match.playlistId}:${match.type}:${match.xtreamId}`; + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + items.push({ + title: recommendation.title, + posterUrl: recommendation.posterUrl, + year: recommendation.year, + match, + }); + } + return items; + } + + /** Route array for one match: the item's detail view in its portal */ + buildLink(item: CrossPortalSimilarItem): string[] { + return [ + '/workspace/xtreams', + item.match.playlistId, + item.match.type === 'movie' ? 'vod' : 'series', + String(item.match.categoryId), + String(item.match.xtreamId), + ]; + } +} diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.ts index c94d86a78..ff1520a72 100644 --- a/libs/shared/interfaces/src/lib/vod-details-adapters.ts +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.ts @@ -30,6 +30,7 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta { ratingKinopoisk: info?.rating_kinopoisk, youtubeTrailer: info?.youtube_trailer, tmdbCast: info?.tmdb_cast, + tmdbRecommendations: info?.tmdb_recommendations, }; } @@ -58,6 +59,7 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta // Stalker portals provide no trailers; TMDB enrichment can youtubeTrailer: info?.tmdb_trailer, tmdbCast: info?.tmdb_cast, + tmdbRecommendations: info?.tmdb_recommendations, }; } diff --git a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts index 07f3f14b7..d66bfab8f 100644 --- a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts +++ b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts @@ -1,6 +1,8 @@ import { XtreamVodDetails } from './xtream-vod-details.interface'; import { StalkerVodDetails } from './stalker-vod-details.interface'; -import { TmdbEnrichedCastMember } from './tmdb.interface'; +import { TmdbEnrichedCastMember, + TmdbRecommendation, +} from './tmdb.interface'; /** * Discriminated union for VOD details across portal types. @@ -82,6 +84,8 @@ export interface NormalizedVodMeta { youtubeTrailer?: string; /** Cast with profile photos, populated by TMDB enrichment */ tmdbCast?: TmdbEnrichedCastMember[]; + /** TMDB recommendations (drives the cross-portal "Similar" rail) */ + tmdbRecommendations?: TmdbRecommendation[]; } /** 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 913645971..59730c10b 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 @@ -190,6 +190,43 @@ }
+ @if (similarInPortals().length) { +
+

{{ 'XTREAM.SIMILAR' | translate }}

+
+ @for (item of similarInPortals(); track item.match.xtreamId) { + + } +
+
+ } + @if (trailerEmbedUrl(); as trailerUrl) {
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 d8e2985e2..27d151643 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 @@ -1,10 +1,4 @@ -import { - Component, - computed, - inject, - input, - output, -} from '@angular/core'; +import { Component, computed, effect, inject, input, output, signal, untracked } from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; import { SafePipe } from '@iptvnator/pipes'; @@ -25,7 +19,11 @@ import { normalizeVodDetails, youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; -import { DownloadsService } from '@iptvnator/services'; +import { + CrossPortalSimilarItem, + CrossPortalSimilarService, + DownloadsService, +} from '@iptvnator/services'; import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component'; @@ -117,6 +115,7 @@ export class VodDetailsComponent { // ============ Services ============ private readonly downloadsService = inject(DownloadsService); + private readonly crossPortalSimilar = inject(CrossPortalSimilarService); private readonly externalPlaybackActions = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly router = inject(Router); @@ -134,6 +133,41 @@ export class VodDetailsComponent { youtubeEmbedUrl(this.normalizedMeta().youtubeTrailer) ); + /** + * TMDB recommendations found in the user's OTHER portals (batched DB + * match, Electron only). Loaded async — the section appears when + * resolved; staleness-guarded against item changes in flight. + */ + readonly similarInPortals = signal([]); + + private readonly loadSimilarInPortals = effect(() => { + const meta = this.normalizedMeta(); + const recommendations = meta.tmdbRecommendations; + untracked(() => { + this.similarInPortals.set([]); + if ( + !recommendations?.length || + !this.crossPortalSimilar.isAvailable + ) { + return; + } + void this.crossPortalSimilar + .matchRecommendations(recommendations, 'movie') + .then((items) => { + if ( + this.normalizedMeta().tmdbRecommendations === + recommendations + ) { + this.similarInPortals.set(items); + } + }); + }); + }); + + openSimilarInPortals(item: CrossPortalSimilarItem): void { + void this.router.navigate(this.crossPortalSimilar.buildLink(item)); + } + /** Whether there's a playback position to resume from */ readonly hasPlaybackPosition = computed(() => { const pos = this.playbackPosition(); diff --git a/libs/ui/styles/_detail-view.scss b/libs/ui/styles/_detail-view.scss index 4abf2f5a5..49f8c0f50 100644 --- a/libs/ui/styles/_detail-view.scss +++ b/libs/ui/styles/_detail-view.scss @@ -269,6 +269,15 @@ text-overflow: ellipsis; white-space: nowrap; } + + // Source playlist badge on cross-portal similar cards + &__similar-source { + color: var(--text-muted); + font-size: 0.6875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } // ============================================================================