mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
1 parent
0131b4ef95
commit
b7e717d127
16 files changed
+582
-18
No files matched your search
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
+47
@@ -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">
|
||||
|
||||
+38
@@ -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,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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in new issue
Block a user