mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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:
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 —
|
||||
|
||||
Reference in new issue
Block a user