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 (item.posterUrl) {
+
+ } @else {
+
+ {{ item.title.charAt(0) }}
+
+ }
+
+ {{ item.title }}
+
+
+ {{ item.match.playlistName }}
+
+
+ }
+
+
+ }
+
@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
+ ) {
+
+ @if (item.posterUrl) {
+
+ } @else {
+
+ {{ item.title.charAt(0) }}
+
+ }
+
+ {{ item.title }}
+
+
+ {{ item.match.playlistName }}
+
+
+ }
}
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
+ ) {
+
+ @if (item.posterUrl) {
+
+ } @else {
+
+ {{ item.title.charAt(0) }}
+
+ }
+
+ {{ item.title }}
+
+
+ {{ item.match.playlistName }}
+
+
+ }
}
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 (item.posterUrl) {
+
+ } @else {
+
+ {{ item.title.charAt(0) }}
+
+ }
+
+ {{ item.title }}
+
+
+ {{ item.match.playlistName }}
+
+
+ }
+
+
+ }
+
@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;
+ }
}
// ============================================================================