feat(tmdb): cross-portal "Similar" rail — Stalker gets it, Xtream gains other-portal matches

The Similar rail only existed on Xtream because it matched against the
locally loaded catalog; Stalker catalogs are server-paginated, so its
detail views had no rail despite tmdb_recommendations being cached.

New CrossPortalSimilarService (libs/services) matches recommendations
against ALL imported Xtream playlists with one batched DB_MATCH_TITLES
worker request — the same two-tier normalized-title + year-compatibility
rule as actor pages and the trending rail. Electron-only; resolves to []
in the PWA.

- Stalker: the shared VodDetailsComponent (movies; covers catalog and
  inline detail hosts) and stalker-series-view (series) now render a
  "Similar" rail from cross-portal matches, each card badged with the
  source playlist and navigating into that portal's detail view.
- Xtream: vod/serial detail rails keep instant local-catalog matches and
  append cross-portal matches (current playlist excluded, deduplicated
  against local hits by normalized exact title), also playlist-badged.
- Loads async after the detail view renders, staleness-guarded; the
  section only appears when there is something to show.

Tests: cross-portal-similar.service.spec.ts (PWA gate, navigation
targets, playlist exclusion, type/year guards). Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-05 11:39:53 +02:00
1 parent 0131b4ef95
commit b7e717d127
16 files changed
+582 -18

No files matched your search

+1 -1
View File
@@ -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`)
+11 -3
View File
@@ -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.
@@ -166,6 +166,53 @@
}
<div detail-extras>
@if (similarInPortals().length) {
<section
class="details__similar"
data-test-id="tmdb-similar"
>
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (
item of similarInPortals();
track item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
<!-- YouTube Trailer (TMDB enrichment; Stalker provides none) -->
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
@@ -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<CrossPortalSimilarItem[]>([]);
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<string | null>(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) {
@@ -165,7 +165,7 @@
/>
<div detail-extras>
@if (similarItems().length) {
@if (similarItems().length || similarInPortals().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@@ -195,6 +195,42 @@
</span>
</button>
}
@for (
item of similarInPortals();
track 'x' + item.match.playlistId + '-' + item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
@@ -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<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
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,
@@ -237,7 +237,7 @@
</div>
}
@if (similarItems().length) {
@if (similarItems().length || similarInPortals().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@@ -267,6 +267,42 @@
</span>
</button>
}
@for (
item of similarInPortals();
track 'x' + item.match.playlistId + '-' + item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
@@ -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<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
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,
+1
View File
@@ -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';
@@ -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> = {}
): 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([]);
});
});
@@ -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<CrossPortalSimilarItem[]> {
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<string>();
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),
];
}
}
@@ -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,
};
}
@@ -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[];
}
/**
@@ -190,6 +190,43 @@
}
<div detail-extras>
@if (similarInPortals().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (item of similarInPortals(); track item.match.xtreamId) {
<button
type="button"
class="details__similar-card"
[title]="item.title + ' — ' + item.match.playlistName"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
<!-- YouTube Trailer (provider or TMDB enrichment) -->
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
@@ -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<CrossPortalSimilarItem[]>([]);
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();
+9
View File
@@ -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;
}
}
// ============================================================================