refactor(portals): extract the series Similar rail into its own service

Rebasing onto master pushed serial-details.component.ts back over the
400-line limit. The "Similar" rail moves into SerialDetailsSimilarService,
mirroring VodDetailsSimilarService next to it: same two sources, same
component-provided lifetime so a cross-portal lookup dies with the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-16 08:18:15 +02:00
1 parent 0ac605fbe8
commit 065437b378
2 files changed
+111 -58

No files matched your search

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