From 44cec1ea36f89a46011df8a3eed4af81371f9004 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 10:08:35 +0200 Subject: [PATCH] refactor(xtream): split the series details page into focused services serial-details.component.ts sat at the 400-line max-lines limit and its playback service and spec were close behind. Move cohesive concerns out without changing behavior: - route params, the provider-only flag and the (re)load of the addressed series go to SerialDetailsRouteService; the component still registers the effect, so effect order is unchanged - season descriptions, posters and the TMDB season enrichment go to SerialDetailsSeasonsService - actor, Similar and Discover navigation go to injectXtreamDetailNavigation, shared with the movie page - the watched toggles and the episode playback payload leave SerialDetailsPlaybackService for SerialDetailsWatchToggles and buildSerialEpisodePlayback - the spec's TestBed moves to a harness and the watched-toggle cases to serial-details.season-watch.spec.ts Counted lines: component 400 -> 318, playback service 388 -> 342, component spec 1196 -> 674. Co-Authored-By: Claude Fable 5.1 --- .../serial-details-playback.service.ts | 97 +-- .../serial-details-route.service.ts | 75 +++ .../serial-details-seasons.service.ts | 75 +++ .../serial-details-watch-toggles.ts | 104 +++ .../serial-details.component.spec.ts | 608 +----------------- .../serial-details.component.ts | 159 +---- .../serial-details/serial-details.harness.ts | 314 +++++++++ .../serial-details.season-watch.spec.ts | 267 ++++++++ .../serial-details.test-stubs.ts | 70 ++ .../serial-details/serial-episode-playback.ts | 58 ++ .../src/lib/xtream-detail-navigation.spec.ts | 114 ++++ .../src/lib/xtream-detail-navigation.ts | 73 +++ 12 files changed, 1225 insertions(+), 789 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-route.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-watch-toggles.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details.season-watch.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-episode-playback.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-detail-navigation.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-detail-navigation.ts diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 023dd595e..0ebc12109 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -23,7 +23,6 @@ import { ExternalPlayerName, ExternalPlayerSession, PlaybackPositionData, - PlayerContentInfo, ResolvedPortalPlayback, XtreamSerieDetails, XtreamSerieEpisode, @@ -37,7 +36,6 @@ import { getSeriesPlaybackNavigation, inlineSeriesEpisodeState, type PlaybackFallbackRequest, - resolveSeriesPlaybackEpisodeState, type SeriesPlaybackEpisodeState, } from '@iptvnator/ui/playback'; import { injectXtreamRecentHistory } from '../xtream-recent-history'; @@ -53,6 +51,8 @@ import { SerialDetailsSeasonWatchService, type SerialDetailsWatchScope, } from './serial-details-season-watch.service'; +import { SerialDetailsWatchToggles } from './serial-details-watch-toggles'; +import { buildSerialEpisodePlayback } from './serial-episode-playback'; export type XtreamSerieDetailsView = XtreamSerieDetails & { readonly series_id: number; @@ -97,6 +97,15 @@ export class SerialDetailsPlaybackService { ); private readonly playbackPositionState = new SerialDetailsPlaybackPositionState(); + private readonly watchToggles = new SerialDetailsWatchToggles({ + playbackPositions: this.playbackPositions, + seasonWatch: this.seasonWatch, + state: this.playbackPositionState, + playlistId: () => this.currentPlaylistId(), + seriesXtreamId: () => Number(this.selectedItem()?.series_id ?? 0), + reloadStorePositions: (playlistId) => + this.xtreamStore.loadAllPositions(playlistId), + }); private lastSaveTime = 0; readonly inlinePlayback = signal(null); @@ -229,32 +238,15 @@ export class SerialDetailsPlaybackService { contentType: 'series', backdropUrl: selectedItem.info?.backdrop_path?.[0], }); - const contentInfo: PlayerContentInfo = { - playlistId: playlist.id, - contentXtreamId: Number(episode.id), - contentType: 'episode', - seriesXtreamId: Number(selectedItem.series_id), - seasonNumber: Number(episode.season), - episodeNumber: Number(episode.episode_num), - }; - const position = this.episodePlaybackPositions().get( Number(episode.id) ); - - const playback: ResolvedPortalPlayback = { + const { playback, episodeState } = buildSerialEpisodePlayback({ + playlistId: playlist.id, + selectedItem, + episode, streamUrl, - title: episode.title, - thumbnail: selectedItem.info.cover, startTime: position?.positionSeconds, - contentInfo, - }; - - const episodeState = resolveSeriesPlaybackEpisodeState({ - episodesBySeason: selectedItem.episodes, - currentEpisodeId: episode.id, - fallbackSeasonNumber: Number(episode.season), - fallbackEpisodeNumber: Number(episode.episode_num), }); return this.startPlayback(playback, episodeState, player); } @@ -322,68 +314,17 @@ export class SerialDetailsPlaybackService { ); } - async handlePlaybackToggleRequested( + handlePlaybackToggleRequested( request: SeasonContainerPlaybackToggleRequest ): Promise { - const playlistId = this.currentPlaylistId(); - if (!playlistId) { - return; - } - - if (request.nextPosition) { - await this.playbackPositions.savePlaybackPosition( - playlistId, - request.nextPosition - ); - this.playbackPositionState.update(request.nextPosition); - } else { - await this.playbackPositions.clearPlaybackPosition( - playlistId, - request.contentXtreamId, - 'episode' - ); - this.playbackPositionState.remove(request.contentXtreamId); - } - await this.refreshStorePositions(playlistId); + return this.watchToggles.toggleEpisode(request); } - async handleWatchToggleRequested( + handleWatchToggleRequested( request: SeasonContainerSeriesPlaybackToggleRequest, scope: SerialDetailsWatchScope ): Promise { - const playlistId = this.currentPlaylistId(); - const seriesXtreamId = Number(this.selectedItem()?.series_id ?? 0); - const persisted = await this.seasonWatch.handle( - request, - playlistId, - this.playbackPositionState, - () => - this.currentPlaylistId() === playlistId && - Number(this.selectedItem()?.series_id ?? 0) === seriesXtreamId, - scope - ); - if (persisted) { - await this.refreshStorePositions(playlistId); - } - } - - /** - * The catalog reads series progress from XtreamStore, whose positions - * load once per playlist (XtreamCatalogFacadeService.initialize), so a - * toggle must push the change back or badges go stale on return. Skipped - * after a playlist switch — the store then holds the other playlist. - */ - private async refreshStorePositions(playlistId: string): Promise { - if (this.currentPlaylistId() !== playlistId) { - return; - } - try { - await this.xtreamStore.loadAllPositions(playlistId); - } catch (error) { - // The toggle itself succeeded; a failed refresh keeps the store - // populated-but-stale, which beats wiping it with a bad read. - this.logger.warn('Store position refresh failed', error); - } + return this.watchToggles.toggleBatch(request, scope); } async loadSeriesPlaybackPositions( diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-route.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-route.service.ts new file mode 100644 index 000000000..c819a3631 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-route.service.ts @@ -0,0 +1,75 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { SerialDetailsPlaybackService } from './serial-details-playback.service'; + +/** + * Route side of the series page: its params, the provider-only flag of the + * navigation that opened it, and the (re)load of the series the route + * addresses. + */ +@Injectable() +export class SerialDetailsRouteService { + private readonly route = inject(ActivatedRoute); + private readonly xtreamStore = inject(XtreamStore); + private readonly playback = inject(SerialDetailsPlaybackService); + /** `playlistId:categoryId:serialId` of the last initialized view */ + private readonly lastInitKey = signal(null); + + /** + * Reactive route params: the component is reused when navigating + * between two series details (e.g. via the Similar rail). + */ + readonly params = toSignal(this.route.params, { + initialValue: this.route.snapshot.params, + }); + readonly providerOnly = computed(() => { + this.params(); + return isProviderOnlyDetailState(window.history.state); + }); + + /** + * Initializes on first render and RE-initializes when the route params + * change while the component is reused (Similar rail). Reads signals, so + * the owning component runs it from an effect. + */ + loadAddressedSeries(): void { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const { categoryId, serialId } = this.params(); + if (!playlistId || !serialId) { + return; + } + + const initKey = `${playlistId}:${categoryId}:${serialId}`; + if (this.lastInitKey() === initKey) { + return; + } + this.lastInitKey.set(initKey); + + this.playback.resetForNewSeries(); + this.initializeSerialDetails(playlistId, categoryId, serialId); + } + + private initializeSerialDetails( + playlistId: string, + categoryId: string | number, + serialId: string + ): void { + this.xtreamStore.fetchSerialDetailsWithMetadata({ + serialId, + categoryId: Number(categoryId), + }); + const serialXtreamId = Number(serialId); + this.xtreamStore.checkFavoriteStatus( + serialXtreamId, + playlistId, + 'series' + ); + void this.playback.loadSeriesPlaybackPositions( + playlistId, + serialXtreamId + ); + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts new file mode 100644 index 000000000..7da3ed964 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts @@ -0,0 +1,75 @@ +import { + computed, + effect, + inject, + Injectable, + Signal, + signal, + untracked, +} from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { buildSeasonDescriptions } from './season-descriptions.util'; +import { buildSeasonPosters } from './season-posters.util'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +interface SerialDetailsSeasonsBindings { + readonly selectedItem: Signal; +} + +/** + * Season presentation of the series page: the descriptions and posters the + * season container and the inline player show, and the TMDB enrichment of + * the season the viewer selected. + */ +@Injectable() +export class SerialDetailsSeasonsService { + private readonly xtreamStore = inject(XtreamStore); + private readonly bindings = signal( + null + ); + private readonly selectedItem = computed( + () => this.bindings()?.selectedItem() ?? null + ); + + /** Season currently selected in the season container. */ + private readonly selectedSeasonKey = signal(null); + + /** Season descriptions (provider text, TMDB fallback, URL junk dropped). */ + readonly descriptions = computed>(() => + buildSeasonDescriptions(this.selectedItem()) + ); + + /** Season posters (TMDB season poster first, provider season cover next). */ + readonly posters = computed>(() => + buildSeasonPosters(this.selectedItem()) + ); + + constructor() { + // TMDB season enrichment, keyed on (tmdb_id, selected season). With + // season tabs the first seasonSelected fires as soon as seasons load — + // usually BEFORE the async show-level TMDB match has written + // info.tmdb_id, and enrichSelectedSerialSeason no-ops without it. So + // the call must re-run when the match arrives, not only on selection. + // The store-side enrichment is idempotent per (serial, season). + effect(() => { + const tmdbId = this.selectedItem()?.info?.tmdb_id; + const seasonKey = this.selectedSeasonKey(); + if (tmdbId && seasonKey) { + untracked(() => + this.xtreamStore.enrichSelectedSerialSeason(seasonKey) + ); + } + }); + } + + /** Connects the service to the owning component's reactive state. */ + bind(bindings: SerialDetailsSeasonsBindings): void { + this.bindings.set(bindings); + } + + select(seasonKey: string): void { + // The enrichment call itself runs from the constructor effect keyed + // on (tmdb_id, selectedSeasonKey) — see the race note there. + this.selectedSeasonKey.set(seasonKey); + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-watch-toggles.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-watch-toggles.ts new file mode 100644 index 000000000..e3df80803 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-watch-toggles.ts @@ -0,0 +1,104 @@ +import { + createLogger, + type PortalPlaybackPositions, +} from '@iptvnator/portal/shared/util'; +import type { + SeasonContainerPlaybackToggleRequest, + SeasonContainerSeriesPlaybackToggleRequest, +} from '@iptvnator/ui/components'; +import type { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state'; +import type { + SerialDetailsSeasonWatchService, + SerialDetailsWatchScope, +} from './serial-details-season-watch.service'; + +const logger = createLogger('SerialDetailsPlayback'); + +/** What the watched toggles of the series page persist through and report to. */ +export interface SerialDetailsWatchToggleDeps { + readonly playbackPositions: Pick< + PortalPlaybackPositions, + 'savePlaybackPosition' | 'clearPlaybackPosition' + >; + readonly seasonWatch: Pick; + readonly state: SerialDetailsPlaybackPositionState; + /** Playlist on screen, empty while none is current. */ + readonly playlistId: () => string; + /** Series on screen, 0 while none is selected. */ + readonly seriesXtreamId: () => number; + /** Reloads the catalog store's positions of a playlist. */ + readonly reloadStorePositions: (playlistId: string) => Promise; +} + +/** + * Episode, season and series watched toggles of the series page: persists + * the change, mirrors it into the page's position state, and pushes it back + * to the catalog store. + */ +export class SerialDetailsWatchToggles { + constructor(private readonly deps: SerialDetailsWatchToggleDeps) {} + + async toggleEpisode( + request: SeasonContainerPlaybackToggleRequest + ): Promise { + const playlistId = this.deps.playlistId(); + if (!playlistId) { + return; + } + + if (request.nextPosition) { + await this.deps.playbackPositions.savePlaybackPosition( + playlistId, + request.nextPosition + ); + this.deps.state.update(request.nextPosition); + } else { + await this.deps.playbackPositions.clearPlaybackPosition( + playlistId, + request.contentXtreamId, + 'episode' + ); + this.deps.state.remove(request.contentXtreamId); + } + await this.refreshStorePositions(playlistId); + } + + async toggleBatch( + request: SeasonContainerSeriesPlaybackToggleRequest, + scope: SerialDetailsWatchScope + ): Promise { + const playlistId = this.deps.playlistId(); + const seriesXtreamId = this.deps.seriesXtreamId(); + const persisted = await this.deps.seasonWatch.handle( + request, + playlistId, + this.deps.state, + () => + this.deps.playlistId() === playlistId && + this.deps.seriesXtreamId() === seriesXtreamId, + scope + ); + if (persisted) { + await this.refreshStorePositions(playlistId); + } + } + + /** + * The catalog reads series progress from XtreamStore, whose positions + * load once per playlist (XtreamCatalogFacadeService.initialize), so a + * toggle must push the change back or badges go stale on return. Skipped + * after a playlist switch — the store then holds the other playlist. + */ + private async refreshStorePositions(playlistId: string): Promise { + if (this.deps.playlistId() !== playlistId) { + return; + } + try { + await this.deps.reloadStorePositions(playlistId); + } catch (error) { + // The toggle itself succeeded; a failed refresh keeps the store + // populated-but-stale, which beats wiping it with a bad read. + logger.warn('Store position refresh failed', error); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 7b2b21177..6cb7cc12d 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -1,361 +1,46 @@ -import { - Component, - input, - output, - signal, - ChangeDetectionStrategy, -} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Location } from '@angular/common'; -import { MatIcon } from '@angular/material/icon'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { ActivatedRoute } from '@angular/router'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { MockPipe } from 'ng-mocks'; -import { - ContentHeroComponent, - SeasonContainerComponent, -} from '@iptvnator/ui/components'; -import { - PORTAL_EXTERNAL_PLAYBACK, - PORTAL_PLAYBACK_POSITIONS, - PORTAL_PLAYER, - SeriesResumeTarget, -} from '@iptvnator/portal/shared/util'; -import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { - DownloadsService, - PlaybackPositionRuntimeBridgeService, - SettingsStore, -} from '@iptvnator/services'; +import { ContentHeroComponent } from '@iptvnator/ui/components'; import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; -import { - PlaybackPositionData, - VideoPlayer, -} from '@iptvnator/shared/interfaces'; -import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; -import { BehaviorSubject, EMPTY, of } from 'rxjs'; +import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { SerialDetailsComponent } from './serial-details.component'; import { SerialDetailsMenuService } from './serial-details-menu.service'; import { SerialDetailsPlaybackService } from './serial-details-playback.service'; -import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; - -@Component({ - selector: 'app-season-container', - standalone: true, - changeDetection: ChangeDetectionStrategy.Eager, - template: '
', -}) -class StubSeasonContainerComponent { - readonly seasons = input(null); - readonly seriesId = input(null); - readonly playlistId = input(''); - readonly seriesTitle = input(undefined); - readonly playbackPositions = input(null); - readonly downloadAdapter = input(null); - readonly downloadsEnabled = input(true); - readonly openingEpisodeId = input(null); - readonly activeEpisodeId = input(null); - readonly playingEpisodeId = input(null); - readonly seasonDescriptions = input(null); - readonly seasonPosters = input(null); - readonly seasonWatchBatchRunning = input(false); - readonly episodeClicked = output(); - readonly playbackToggleRequested = output(); - readonly seasonPlaybackToggleRequested = output(); -} - -@Component({ - selector: 'app-portal-inline-player', - standalone: true, - changeDetection: ChangeDetectionStrategy.Eager, - template: '', -}) -class StubPortalInlinePlayerComponent { - readonly playbackSessionKey = input.required(); - readonly playback = input(null); - readonly episodeMetadata = input(null); - readonly seriesTitle = input(null); - readonly seriesNavigation = input(null); - readonly upNextEpisodes = input(null); - readonly seriesEpisodes = input(null); - readonly seasonPosters = input(null); - readonly episodePlaybackPositions = input(null); - readonly seasonLoadStates = input(null); - readonly timeUpdate = output(); - readonly closed = output(); - readonly streamUrlCopied = output(); - readonly externalFallbackRequested = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); - readonly upNextEpisodeSelected = output(); - readonly episodePanelSeasonSelected = output(); -} - -@Component({ - selector: 'mat-icon', - standalone: true, - changeDetection: ChangeDetectionStrategy.Eager, - template: '', -}) -class StubMatIconComponent {} +import { + configureSerialDetailsTestBed, + createSerialDetailsStubs, + resetSerialDetailsStubs, +} from './serial-details.harness'; +import { + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, +} from './serial-details.test-stubs'; describe('SerialDetailsComponent', () => { let fixture: ComponentFixture; - const selectedItem = signal(null); - const selectedContentType = signal<'series'>('series'); - const isFavorite = signal(false); - const isLoadingDetails = signal(false); - const detailsError = signal(null); - const currentPlaylist = signal({ - id: 'xtream-1', - serverUrl: 'http://xtream.example', - username: 'user', - password: 'pass', - userAgent: 'ProtectedProvider/2.0', - referrer: 'https://referrer.example/series', - origin: 'https://origin.example', - }); - const fetchSerialDetailsWithMetadata = jest.fn(); - const cancelDetailsRequest = jest.fn(); - const checkFavoriteStatus = jest.fn(); - const constructEpisodeStreamUrl = jest.fn(); - const addRecentItem = jest.fn(); - const openResolvedPlayback = jest.fn(); - const openExternalPlayback = jest.fn(); - const savePlaybackPosition = jest.fn(); - const clearPlaybackPosition = jest.fn(); - const savePlaybackPositionsBatch = jest.fn(); - const clearPlaybackPositionsBatch = jest.fn(); - const loadAllPositions = jest.fn(); - const isEmbeddedPlayer = jest.fn(); - const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); - let positionUpdateCallback: ((data: PlaybackPositionData) => void) | null = - null; - let seriesResumeTarget: ReturnType< - typeof signal - >; - let routeParams: BehaviorSubject<{ - categoryId: string; - serialId: string; - }>; + const stubs = createSerialDetailsStubs(); + const { + selectedItem, + currentPlaylist, + fetchSerialDetailsWithMetadata, + cancelDetailsRequest, + checkFavoriteStatus, + constructEpisodeStreamUrl, + addRecentItem, + openResolvedPlayback, + openExternalPlayback, + savePlaybackPosition, + isEmbeddedPlayer, + getSeriesPlaybackPositions, + seriesResumeTarget, + routeParams, + } = stubs; beforeEach(async () => { window.history.replaceState({}, '', window.location.href); - selectedItem.set({ - series_id: 103, - info: { - name: 'Series One', - plot: 'Series plot', - cover: 'cover.jpg', - backdrop_path: [], - genre: 'Drama', - category_id: '3', - tmdb_id: 901, - tmdb_cast: [{ name: 'Sienna Wave', character: 'Mara' }], - }, - episodes: { - '1': [ - { - id: '1001', - episode_num: 1, - title: 'Episode 1', - season: 1, - }, - { - id: '1002', - episode_num: 2, - title: 'Episode 2', - season: 1, - }, - ], - '2': [ - { - id: '2001', - episode_num: 1, - title: 'Season 2 Episode 1', - season: 2, - }, - ], - }, - }); - isFavorite.set(false); - isLoadingDetails.set(false); - detailsError.set(null); - fetchSerialDetailsWithMetadata.mockClear(); - cancelDetailsRequest.mockClear(); - checkFavoriteStatus.mockClear(); - constructEpisodeStreamUrl.mockReset(); - constructEpisodeStreamUrl.mockImplementation( - (episode: { id: string | number }) => - `http://xtream.example/series/${episode.id}.mp4` - ); - addRecentItem.mockClear(); - openResolvedPlayback.mockReset(); - openResolvedPlayback.mockResolvedValue(undefined); - openExternalPlayback.mockReset(); - openExternalPlayback.mockResolvedValue(undefined); - savePlaybackPosition.mockReset(); - savePlaybackPosition.mockResolvedValue(undefined); - clearPlaybackPosition.mockReset(); - clearPlaybackPosition.mockResolvedValue(undefined); - savePlaybackPositionsBatch.mockReset(); - savePlaybackPositionsBatch.mockResolvedValue(undefined); - clearPlaybackPositionsBatch.mockReset(); - clearPlaybackPositionsBatch.mockResolvedValue(undefined); - loadAllPositions.mockReset(); - loadAllPositions.mockResolvedValue(undefined); - positionUpdateCallback = null; - isEmbeddedPlayer.mockReset(); - isEmbeddedPlayer.mockReturnValue(false); - getSeriesPlaybackPositions.mockClear(); - getSeriesPlaybackPositions.mockResolvedValue([]); - seriesResumeTarget = signal(null); - routeParams = new BehaviorSubject({ - categoryId: '3', - serialId: '103', - }); - - await TestBed.configureTestingModule({ - imports: [SerialDetailsComponent], - providers: [ - { - provide: ActivatedRoute, - useValue: { - params: routeParams, - snapshot: { - params: { - categoryId: '3', - serialId: '103', - }, - }, - }, - }, - { - provide: XtreamStore, - useValue: { - selectedItem, - selectedContentType, - isFavorite, - isLoadingDetails, - detailsError, - currentPlaylist, - fetchSerialDetailsWithMetadata, - cancelDetailsRequest, - checkFavoriteStatus, - setSelectedItem: jest.fn((value: unknown) => - selectedItem.set(value) - ), - toggleFavorite: jest.fn(), - constructEpisodeStreamUrl, - addRecentItem, - backfillContentMetadata: jest.fn(), - loadAllPositions, - recentItems: signal([]), - serialCategories: signal([]), - loadRecentItems: jest.fn(), - }, - }, - { - provide: SettingsStore, - useValue: { player: signal(VideoPlayer.Html5Player) }, - }, - { - provide: DownloadsService, - useValue: { isAvailable: signal(false) }, - }, - { - provide: PORTAL_EXTERNAL_PLAYBACK, - useValue: { - activeSession: signal(null), - }, - }, - { - provide: PORTAL_PLAYBACK_POSITIONS, - useValue: { - getSeriesPlaybackPositions, - savePlaybackPosition, - clearPlaybackPosition, - savePlaybackPositionsBatch, - clearPlaybackPositionsBatch, - }, - }, - { - provide: PORTAL_PLAYER, - useValue: { - isEmbeddedPlayer, - openResolvedPlayback, - openExternalPlayback, - }, - }, - { - provide: PlaybackPositionRuntimeBridgeService, - useValue: { - onPlaybackPositionUpdate: ( - callback: (data: PlaybackPositionData) => void - ) => { - positionUpdateCallback = callback; - return () => { - positionUpdateCallback = null; - }; - }, - }, - }, - { - provide: XTREAM_SERIES_RESUME_TARGET, - useValue: seriesResumeTarget, - }, - { - provide: MatSnackBar, - useValue: { - open: jest.fn(), - }, - }, - { - provide: TranslateService, - useValue: { - instant: (key: string) => key, - get: (key: string) => of(key), - stream: (key: string) => of(key), - onLangChange: EMPTY, - onTranslationChange: EMPTY, - onDefaultLangChange: EMPTY, - }, - }, - { - provide: Location, - useValue: { - back: jest.fn(), - }, - }, - ], - }) - .overrideComponent(SerialDetailsComponent, { - remove: { - imports: [ - MatIcon, - PortalInlinePlayerComponent, - SeasonContainerComponent, - TranslatePipe, - ], - }, - add: { - imports: [ - StubMatIconComponent, - StubPortalInlinePlayerComponent, - StubSeasonContainerComponent, - MockPipe( - TranslatePipe, - (value: string | null | undefined) => value ?? '' - ), - ], - }, - }) - .compileComponents(); + resetSerialDetailsStubs(stubs); + await configureSerialDetailsTestBed(stubs); fixture = TestBed.createComponent(SerialDetailsComponent); }); @@ -779,6 +464,7 @@ describe('SerialDetailsComponent', () => { it('applies streamed playback-position updates for the selected series only', async () => { fixture.detectChanges(); await fixture.whenStable(); + const positionUpdateCallback = stubs.positionUpdates.callback; if (!positionUpdateCallback) { throw new Error('expected a playback-position subscription'); } @@ -936,238 +622,6 @@ describe('SerialDetailsComponent', () => { expect(savePlaybackPosition).not.toHaveBeenCalled(); }); - it('saves and clears positions for season-container toggle requests', async () => { - fixture.detectChanges(); - await fixture.whenStable(); - - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - await playbackService.handlePlaybackToggleRequested({ - contentXtreamId: 1001, - nextPosition: { - playlistId: 'xtream-1', - contentXtreamId: 1001, - contentType: 'episode', - seriesXtreamId: 103, - seasonNumber: 1, - episodeNumber: 1, - positionSeconds: 950, - durationSeconds: 1000, - }, - } as never); - expect(savePlaybackPosition).toHaveBeenCalledWith( - 'xtream-1', - expect.objectContaining({ contentXtreamId: 1001 }) - ); - - await playbackService.handlePlaybackToggleRequested({ - contentXtreamId: 1001, - nextPosition: null, - } as never); - expect(clearPlaybackPosition).toHaveBeenCalledWith( - 'xtream-1', - 1001, - 'episode' - ); - }); - - it('marks a season watched through one batch save and updates rendered positions', async () => { - fixture.detectChanges(); - await fixture.whenStable(); - - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - const snackBar = TestBed.inject(MatSnackBar); - const seasonPosition = ( - contentXtreamId: number, - episodeNumber: number - ) => ({ - playlistId: 'xtream-1', - contentXtreamId, - contentType: 'episode' as const, - seriesXtreamId: 103, - seasonNumber: 1, - episodeNumber, - positionSeconds: 1200, - durationSeconds: 1200, - }); - - await playbackService.handleWatchToggleRequested( - { - seasonKey: '1', - markWatched: true, - requests: [ - { - contentXtreamId: 1001, - nextPosition: seasonPosition(1001, 1), - }, - { - contentXtreamId: 1002, - nextPosition: seasonPosition(1002, 2), - }, - ], - } as never, - 'season' - ); - - expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1); - expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ - expect.objectContaining({ contentXtreamId: 1001 }), - expect.objectContaining({ contentXtreamId: 1002 }), - ]); - expect(savePlaybackPosition).not.toHaveBeenCalled(); - expect(playbackService.episodePlaybackPositions().get(1001)).toEqual( - expect.objectContaining({ positionSeconds: 1200 }) - ); - expect( - playbackService.episodePlaybackPositions().get(1002) - ).toBeDefined(); - expect(snackBar.open).toHaveBeenCalledWith( - 'XTREAM.SEASON_MARKED_WATCHED', - undefined, - { duration: 5000 } - ); - // The catalog badge source must follow the batch. - expect(loadAllPositions).toHaveBeenCalledWith('xtream-1'); - expect(playbackService.seasonWatchBatchRunning()).toBe(false); - }); - - it('unwatches a season through one batch clear', async () => { - fixture.detectChanges(); - await fixture.whenStable(); - - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - await playbackService.handleWatchToggleRequested( - { - seasonKey: '1', - markWatched: false, - requests: [ - { contentXtreamId: 1001, nextPosition: null }, - { contentXtreamId: 1002, nextPosition: null }, - ], - } as never, - 'season' - ); - - expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1); - expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ - { contentXtreamId: 1001, contentType: 'episode' }, - { contentXtreamId: 1002, contentType: 'episode' }, - ]); - expect(clearPlaybackPosition).not.toHaveBeenCalled(); - expect(playbackService.episodePlaybackPositions().has(1001)).toBe( - false - ); - }); - - it('does not write a stale season batch into another playlist state', async () => { - fixture.detectChanges(); - await fixture.whenStable(); - - let resolveBatch!: () => void; - savePlaybackPositionsBatch.mockImplementation( - () => - new Promise((resolve) => { - resolveBatch = resolve; - }) - ); - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - const pending = playbackService.handleWatchToggleRequested( - { - seasonKey: '1', - markWatched: true, - requests: [ - { - contentXtreamId: 1001, - nextPosition: { - playlistId: 'xtream-1', - contentXtreamId: 1001, - contentType: 'episode', - seriesXtreamId: 103, - seasonNumber: 1, - episodeNumber: 1, - positionSeconds: 1200, - durationSeconds: 1200, - }, - }, - ], - } as never, - 'season' - ); - - // The user navigates to another playlist while the batch is pending. - const initialPlaylist = currentPlaylist(); - currentPlaylist.set({ ...initialPlaylist, id: 'xtream-2' }); - resolveBatch(); - await pending; - - expect(savePlaybackPositionsBatch).toHaveBeenCalledWith( - 'xtream-1', - expect.anything() - ); - expect(playbackService.episodePlaybackPositions().has(1001)).toBe( - false - ); - expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); - // The store now belongs to the other playlist — no stale refresh. - expect(loadAllPositions).not.toHaveBeenCalled(); - expect(playbackService.seasonWatchBatchRunning()).toBe(false); - currentPlaylist.set(initialPlaylist); - }); - - it('keeps rendered positions and reports the error when the season batch fails', async () => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed')); - fixture.detectChanges(); - await fixture.whenStable(); - - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - const snackBar = TestBed.inject(MatSnackBar); - await playbackService.handleWatchToggleRequested( - { - seasonKey: '1', - markWatched: true, - requests: [ - { - contentXtreamId: 1001, - nextPosition: { - playlistId: 'xtream-1', - contentXtreamId: 1001, - contentType: 'episode', - seriesXtreamId: 103, - seasonNumber: 1, - episodeNumber: 1, - positionSeconds: 1200, - durationSeconds: 1200, - }, - }, - ], - } as never, - 'season' - ); - - expect(playbackService.episodePlaybackPositions().has(1001)).toBe( - false - ); - expect(snackBar.open).toHaveBeenCalledWith( - 'XTREAM.SEASON_WATCH_UPDATE_FAILED', - undefined, - { duration: 5000 } - ); - expect(playbackService.seasonWatchBatchRunning()).toBe(false); - consoleError.mockRestore(); - }); - it('passes inline episode metadata and autoplays only inside the current season', async () => { isEmbeddedPlayer.mockReturnValue(true); fixture.detectChanges(); 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 9c40d2b12..247193ad0 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 @@ -6,13 +6,11 @@ import { inject, OnDestroy, signal, - untracked, ChangeDetectionStrategy, viewChild, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CastCrewRowComponent, @@ -49,24 +47,15 @@ import { XtreamSerieEpisode, XtreamSerieInfo, } from '@iptvnator/shared/interfaces'; -import { buildSeasonDescriptions } from './season-descriptions.util'; -import { buildSeasonPosters } from './season-posters.util'; -import { - createDiscoverFacetNavigation, - isProviderOnlyDetailState, -} from '@iptvnator/portal/shared/util'; -import { - CrossPortalSimilarItem, - CrossPortalSimilarService, - TmdbEnrichmentService, -} from '@iptvnator/services'; +import { injectXtreamDetailNavigation } from '../xtream-detail-navigation'; import { SerialDetailsPlaybackService, type XtreamSerieDetailsView, } from './serial-details-playback.service'; +import { SerialDetailsRouteService } from './serial-details-route.service'; import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service'; +import { SerialDetailsSeasonsService } from './serial-details-seasons.service'; import { SerialDetailsSimilarService } from './serial-details-similar.service'; -import { SimilarCatalogItem } from '../tmdb-similar.util'; import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; import { SerialDetailsHeroPresenter } from './serial-details-hero.presenter'; import { SerialDetailsMenuService } from './serial-details-menu.service'; @@ -90,7 +79,9 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a ], providers: [ SerialDetailsPlaybackService, + SerialDetailsRouteService, SerialDetailsSeasonWatchService, + SerialDetailsSeasonsService, SerialDetailsSimilarService, SerialDetailsHeroPresenter, SerialDetailsMenuService, @@ -118,8 +109,7 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a export class SerialDetailsComponent implements OnDestroy { private readonly location = inject(Location); private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly crossPortalSimilar = inject(CrossPortalSimilarService); + private readonly navigation = injectXtreamDetailNavigation('tv'); private readonly xtreamStore = inject(XtreamStore); private readonly playback = inject(SerialDetailsPlaybackService); @@ -142,20 +132,9 @@ export class SerialDetailsComponent implements OnDestroy { SerialDetailsDownloadAdapterService ); readonly episodeDownloadAdapter = this.downloadAdapter.adapter; - /** `playlistId:categoryId:serialId` of the last initialized view */ - private readonly lastInitKey = signal(null); - - /** - * Reactive route params: the component is reused when navigating - * between two series details (e.g. via the Similar rail). - */ - private readonly routeParams = toSignal(this.route.params, { - initialValue: this.route.snapshot.params, - }); - readonly providerOnly = computed(() => { - this.routeParams(); - return isProviderOnlyDetailState(window.history.state); - }); + private readonly routeState = inject(SerialDetailsRouteService); + private readonly routeParams = this.routeState.params; + readonly providerOnly = this.routeState.providerOnly; // Episode playback state, re-exposed for the template. readonly inlinePlayback = this.playback.inlinePlayback; @@ -182,27 +161,23 @@ export class SerialDetailsComponent implements OnDestroy { }) ); - /** Season currently selected in the season container. */ - private readonly selectedSeasonKey = signal(null); - - /** Season descriptions (provider text, TMDB fallback, URL junk dropped). */ - readonly seasonDescriptions = computed>(() => - buildSeasonDescriptions(this.selectedItem()) - ); - - /** Season posters (TMDB season poster first, provider season cover next). */ - readonly seasonPosters = computed>(() => - buildSeasonPosters(this.selectedItem()) - ); - /** The "Similar" rail: catalog matches plus cross-portal matches */ private readonly similar = inject(SerialDetailsSimilarService); readonly similarItems = this.similar.similarItems; readonly similarInPortals = this.similar.similarInPortals; + /** Season descriptions and posters, and the selected season's enrichment. */ + private readonly seasons = inject(SerialDetailsSeasonsService); + readonly seasonDescriptions = this.seasons.descriptions; + readonly seasonPosters = this.seasons.posters; + + /** Clickable year/genre/country chips (Discover pages) */ + readonly discover = this.navigation.discover; + constructor() { this.playback.bind({ selectedItem: this.selectedItem }); this.similar.bind({ selectedItem: this.selectedItem }); + this.seasons.bind({ selectedItem: this.selectedItem }); this.downloadAdapter.bind(this.selectedItem); this.heroPresenter.bind({ selectedItem: this.selectedItem, @@ -210,8 +185,9 @@ export class SerialDetailsComponent implements OnDestroy { yearLabel: (releaseDate) => this.discover.yearLabel(releaseDate), similarItems: this.similarItems, similarInPortals: this.similarInPortals, - openSimilar: (item) => this.openSimilar(item), - openSimilarInPortals: (item) => this.openSimilarInPortals(item), + openSimilar: (item) => this.navigation.openSimilar(item), + openSimilarInPortals: (item) => + this.navigation.openSimilarInPortals(item), }); this.menu.bind({ selectedItem: this.selectedItem, @@ -235,22 +211,6 @@ export class SerialDetailsComponent implements OnDestroy { this.playback.playEpisode(episode, player), }); - // TMDB season enrichment, keyed on (tmdb_id, selected season). With - // season tabs the first seasonSelected fires as soon as seasons load — - // usually BEFORE the async show-level TMDB match has written - // info.tmdb_id, and enrichSelectedSerialSeason no-ops without it. So - // the call must re-run when the match arrives, not only on selection. - // The store-side enrichment is idempotent per (serial, season). - effect(() => { - const tmdbId = this.selectedItem()?.info?.tmdb_id; - const seasonKey = this.selectedSeasonKey(); - if (tmdbId && seasonKey) { - untracked(() => - this.xtreamStore.enrichSelectedSerialSeason(seasonKey) - ); - } - }); - effect(() => { const item = this.xtreamStore.selectedItem() as unknown as | (XtreamSerieDetails & { @@ -274,22 +234,7 @@ export class SerialDetailsComponent implements OnDestroy { // Initializes on first render and RE-initializes when the route // params change while the component is reused (Similar rail). - effect(() => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - const { categoryId, serialId } = this.routeParams(); - if (!playlistId || !serialId) { - return; - } - - const initKey = `${playlistId}:${categoryId}:${serialId}`; - if (this.lastInitKey() === initKey) { - return; - } - this.lastInitKey.set(initKey); - - this.playback.resetForNewSeries(); - this.initializeSerialDetails(playlistId, categoryId, serialId); - }); + effect(() => this.routeState.loadAddressedSeries()); registerContentMetadataBackfill({ store: this.xtreamStore, @@ -306,10 +251,6 @@ export class SerialDetailsComponent implements OnDestroy { this.xtreamStore.setSelectedItem(null); } - openSimilarInPortals(item: CrossPortalSimilarItem): void { - void this.router.navigate(this.crossPortalSimilar.buildLink(item)); - } - /** Clears every saved episode position of the series. */ resetProgress(): Promise { const request = @@ -319,41 +260,12 @@ export class SerialDetailsComponent implements OnDestroy { : Promise.resolve(); } - openSimilar(item: SimilarCatalogItem): void { - void this.router.navigate(['../..', item.categoryId, item.id], { - relativeTo: this.route, - }); - } - openActor(member: TmdbEnrichedCastMember): void { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId || !member.tmdbPersonId) { - return; - } - void this.router.navigate([ - '/workspace/xtreams', - playlistId, - 'actor', - member.tmdbPersonId, - ]); + this.navigation.openActor(member); } - /** Clickable year/genre/country chips (Discover pages) */ - private readonly tmdbEnrichment = inject(TmdbEnrichmentService); - - readonly discover = createDiscoverFacetNavigation(() => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - // Discover reads its results from TMDB, so a chip must not offer a - // page that enrichment cannot fill - return playlistId && this.tmdbEnrichment.isEnabled() - ? { portal: 'xtream', mediaType: 'tv', playlistId } - : null; - }); - onSeasonSelected(seasonKey: string): void { - // The enrichment call itself runs from the constructor effect keyed - // on (tmdb_id, selectedSeasonKey) — see the race note there. - this.selectedSeasonKey.set(seasonKey); + this.seasons.select(seasonKey); } playEpisode(episode: XtreamSerieEpisode): void { @@ -445,25 +357,4 @@ export class SerialDetailsComponent implements OnDestroy { } ); } - - private initializeSerialDetails( - playlistId: string, - categoryId: string | number, - serialId: string - ): void { - this.xtreamStore.fetchSerialDetailsWithMetadata({ - serialId, - categoryId: Number(categoryId), - }); - const serialXtreamId = Number(serialId); - this.xtreamStore.checkFavoriteStatus( - serialXtreamId, - playlistId, - 'series' - ); - void this.playback.loadSeriesPlaybackPositions( - playlistId, - serialXtreamId - ); - } } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts new file mode 100644 index 000000000..5a57feb7f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts @@ -0,0 +1,314 @@ +import { Location } from '@angular/common'; +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatIcon } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute } from '@angular/router'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, + SeriesResumeTarget, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + DownloadsService, + PlaybackPositionRuntimeBridgeService, + SettingsStore, +} from '@iptvnator/services'; +import { + PlaybackPositionData, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { SeasonContainerComponent } from '@iptvnator/ui/components'; +import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; +import { BehaviorSubject, EMPTY, of } from 'rxjs'; +import { SerialDetailsComponent } from './serial-details.component'; +import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token'; +import { + StubMatIconComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, +} from './serial-details.test-stubs'; + +/** + * The TestBed every series-details component spec needs. + * + * The page pulls in the store, the playback ports, the position bridge, + * downloads, settings and the router, so standing it up costs ~200 lines. + * One harness keeps the specs split by concern from carrying a copy each. + */ + +/** The series every spec starts from: two seasons, three episodes. */ +function createSeriesFixture(): unknown { + return { + series_id: 103, + info: { + name: 'Series One', + plot: 'Series plot', + cover: 'cover.jpg', + backdrop_path: [], + genre: 'Drama', + category_id: '3', + tmdb_id: 901, + tmdb_cast: [{ name: 'Sienna Wave', character: 'Mara' }], + }, + episodes: { + '1': [ + { + id: '1001', + episode_num: 1, + title: 'Episode 1', + season: 1, + }, + { + id: '1002', + episode_num: 2, + title: 'Episode 2', + season: 1, + }, + ], + '2': [ + { + id: '2001', + episode_num: 1, + title: 'Season 2 Episode 1', + season: 2, + }, + ], + }, + }; +} + +const initialPlaylist = { + id: 'xtream-1', + serverUrl: 'http://xtream.example', + username: 'user', + password: 'pass', + userAgent: 'ProtectedProvider/2.0', + referrer: 'https://referrer.example/series', + origin: 'https://origin.example', +}; + +/** Every stub the harness installs, so specs can drive and assert on them. */ +export function createSerialDetailsStubs() { + return { + selectedItem: signal(null), + selectedContentType: signal<'series'>('series'), + isFavorite: signal(false), + isLoadingDetails: signal(false), + detailsError: signal(null), + currentPlaylist: signal(initialPlaylist), + fetchSerialDetailsWithMetadata: jest.fn(), + cancelDetailsRequest: jest.fn(), + checkFavoriteStatus: jest.fn(), + constructEpisodeStreamUrl: jest.fn(), + addRecentItem: jest.fn(), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + savePlaybackPositionsBatch: jest.fn(), + clearPlaybackPositionsBatch: jest.fn(), + loadAllPositions: jest.fn(), + isEmbeddedPlayer: jest.fn(), + getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]), + seriesResumeTarget: signal(null), + routeParams: new BehaviorSubject({ + categoryId: '3', + serialId: '103', + }), + /** The page's subscription to streamed position updates, once made. */ + positionUpdates: { + callback: null as ((data: PlaybackPositionData) => void) | null, + }, + }; +} + +export type SerialDetailsStubs = ReturnType; + +/** Back to the state a fresh `beforeEach` expects. */ +export function resetSerialDetailsStubs(stubs: SerialDetailsStubs): void { + stubs.selectedItem.set(createSeriesFixture()); + stubs.isFavorite.set(false); + stubs.isLoadingDetails.set(false); + stubs.detailsError.set(null); + stubs.currentPlaylist.set(initialPlaylist); + stubs.fetchSerialDetailsWithMetadata.mockClear(); + stubs.cancelDetailsRequest.mockClear(); + stubs.checkFavoriteStatus.mockClear(); + stubs.constructEpisodeStreamUrl.mockReset(); + stubs.constructEpisodeStreamUrl.mockImplementation( + (episode: { id: string | number }) => + `http://xtream.example/series/${episode.id}.mp4` + ); + stubs.addRecentItem.mockClear(); + stubs.openResolvedPlayback.mockReset(); + stubs.openResolvedPlayback.mockResolvedValue(undefined); + stubs.openExternalPlayback.mockReset(); + stubs.openExternalPlayback.mockResolvedValue(undefined); + stubs.savePlaybackPosition.mockReset(); + stubs.savePlaybackPosition.mockResolvedValue(undefined); + stubs.clearPlaybackPosition.mockReset(); + stubs.clearPlaybackPosition.mockResolvedValue(undefined); + stubs.savePlaybackPositionsBatch.mockReset(); + stubs.savePlaybackPositionsBatch.mockResolvedValue(undefined); + stubs.clearPlaybackPositionsBatch.mockReset(); + stubs.clearPlaybackPositionsBatch.mockResolvedValue(undefined); + stubs.loadAllPositions.mockReset(); + stubs.loadAllPositions.mockResolvedValue(undefined); + stubs.positionUpdates.callback = null; + stubs.isEmbeddedPlayer.mockReset(); + stubs.isEmbeddedPlayer.mockReturnValue(false); + stubs.getSeriesPlaybackPositions.mockClear(); + stubs.getSeriesPlaybackPositions.mockResolvedValue([]); + stubs.seriesResumeTarget.set(null); + stubs.routeParams.next({ categoryId: '3', serialId: '103' }); +} + +export async function configureSerialDetailsTestBed( + stubs: SerialDetailsStubs +): Promise { + await TestBed.configureTestingModule({ + imports: [SerialDetailsComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: stubs.routeParams, + snapshot: { + params: { + categoryId: '3', + serialId: '103', + }, + }, + }, + }, + { + provide: XtreamStore, + useValue: { + selectedItem: stubs.selectedItem, + selectedContentType: stubs.selectedContentType, + isFavorite: stubs.isFavorite, + isLoadingDetails: stubs.isLoadingDetails, + detailsError: stubs.detailsError, + currentPlaylist: stubs.currentPlaylist, + fetchSerialDetailsWithMetadata: + stubs.fetchSerialDetailsWithMetadata, + cancelDetailsRequest: stubs.cancelDetailsRequest, + checkFavoriteStatus: stubs.checkFavoriteStatus, + setSelectedItem: jest.fn((value: unknown) => + stubs.selectedItem.set(value) + ), + toggleFavorite: jest.fn(), + constructEpisodeStreamUrl: stubs.constructEpisodeStreamUrl, + addRecentItem: stubs.addRecentItem, + backfillContentMetadata: jest.fn(), + loadAllPositions: stubs.loadAllPositions, + recentItems: signal([]), + serialCategories: signal([]), + loadRecentItems: jest.fn(), + }, + }, + { + provide: SettingsStore, + useValue: { player: signal(VideoPlayer.Html5Player) }, + }, + { + provide: DownloadsService, + useValue: { isAvailable: signal(false) }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions: + stubs.getSeriesPlaybackPositions, + savePlaybackPosition: stubs.savePlaybackPosition, + clearPlaybackPosition: stubs.clearPlaybackPosition, + savePlaybackPositionsBatch: + stubs.savePlaybackPositionsBatch, + clearPlaybackPositionsBatch: + stubs.clearPlaybackPositionsBatch, + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: stubs.isEmbeddedPlayer, + openResolvedPlayback: stubs.openResolvedPlayback, + openExternalPlayback: stubs.openExternalPlayback, + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: ( + callback: (data: PlaybackPositionData) => void + ) => { + stubs.positionUpdates.callback = callback; + return () => { + stubs.positionUpdates.callback = null; + }; + }, + }, + }, + { + provide: XTREAM_SERIES_RESUME_TARGET, + useValue: stubs.seriesResumeTarget, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + { + provide: Location, + useValue: { + back: jest.fn(), + }, + }, + ], + }) + .overrideComponent(SerialDetailsComponent, { + remove: { + imports: [ + MatIcon, + PortalInlinePlayerComponent, + SeasonContainerComponent, + TranslatePipe, + ], + }, + add: { + imports: [ + StubMatIconComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.season-watch.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.season-watch.spec.ts new file mode 100644 index 000000000..a4e9252b2 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.season-watch.spec.ts @@ -0,0 +1,267 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { SerialDetailsComponent } from './serial-details.component'; +import { SerialDetailsPlaybackService } from './serial-details-playback.service'; +import { + configureSerialDetailsTestBed, + createSerialDetailsStubs, + resetSerialDetailsStubs, +} from './serial-details.harness'; + +describe('SerialDetailsComponent watched toggles', () => { + let fixture: ComponentFixture; + const stubs = createSerialDetailsStubs(); + const { + currentPlaylist, + savePlaybackPosition, + clearPlaybackPosition, + savePlaybackPositionsBatch, + clearPlaybackPositionsBatch, + loadAllPositions, + } = stubs; + + beforeEach(async () => { + window.history.replaceState({}, '', window.location.href); + resetSerialDetailsStubs(stubs); + await configureSerialDetailsTestBed(stubs); + + fixture = TestBed.createComponent(SerialDetailsComponent); + }); + + afterEach(() => { + window.history.replaceState({}, '', window.location.href); + fixture?.destroy(); + }); + + it('saves and clears positions for season-container toggle requests', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + await playbackService.handlePlaybackToggleRequested({ + contentXtreamId: 1001, + nextPosition: { + playlistId: 'xtream-1', + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 950, + durationSeconds: 1000, + }, + } as never); + expect(savePlaybackPosition).toHaveBeenCalledWith( + 'xtream-1', + expect.objectContaining({ contentXtreamId: 1001 }) + ); + + await playbackService.handlePlaybackToggleRequested({ + contentXtreamId: 1001, + nextPosition: null, + } as never); + expect(clearPlaybackPosition).toHaveBeenCalledWith( + 'xtream-1', + 1001, + 'episode' + ); + }); + + it('marks a season watched through one batch save and updates rendered positions', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const snackBar = TestBed.inject(MatSnackBar); + const seasonPosition = ( + contentXtreamId: number, + episodeNumber: number + ) => ({ + playlistId: 'xtream-1', + contentXtreamId, + contentType: 'episode' as const, + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber, + positionSeconds: 1200, + durationSeconds: 1200, + }); + + await playbackService.handleWatchToggleRequested( + { + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 1001, + nextPosition: seasonPosition(1001, 1), + }, + { + contentXtreamId: 1002, + nextPosition: seasonPosition(1002, 2), + }, + ], + } as never, + 'season' + ); + + expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1); + expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ + expect.objectContaining({ contentXtreamId: 1001 }), + expect.objectContaining({ contentXtreamId: 1002 }), + ]); + expect(savePlaybackPosition).not.toHaveBeenCalled(); + expect(playbackService.episodePlaybackPositions().get(1001)).toEqual( + expect.objectContaining({ positionSeconds: 1200 }) + ); + expect( + playbackService.episodePlaybackPositions().get(1002) + ).toBeDefined(); + expect(snackBar.open).toHaveBeenCalledWith( + 'XTREAM.SEASON_MARKED_WATCHED', + undefined, + { duration: 5000 } + ); + // The catalog badge source must follow the batch. + expect(loadAllPositions).toHaveBeenCalledWith('xtream-1'); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + }); + + it('unwatches a season through one batch clear', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + await playbackService.handleWatchToggleRequested( + { + seasonKey: '1', + markWatched: false, + requests: [ + { contentXtreamId: 1001, nextPosition: null }, + { contentXtreamId: 1002, nextPosition: null }, + ], + } as never, + 'season' + ); + + expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1); + expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ + { contentXtreamId: 1001, contentType: 'episode' }, + { contentXtreamId: 1002, contentType: 'episode' }, + ]); + expect(clearPlaybackPosition).not.toHaveBeenCalled(); + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + }); + + it('does not write a stale season batch into another playlist state', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + let resolveBatch!: () => void; + savePlaybackPositionsBatch.mockImplementation( + () => + new Promise((resolve) => { + resolveBatch = resolve; + }) + ); + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const pending = playbackService.handleWatchToggleRequested( + { + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 1001, + nextPosition: { + playlistId: 'xtream-1', + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 1200, + durationSeconds: 1200, + }, + }, + ], + } as never, + 'season' + ); + + // The user navigates to another playlist while the batch is pending. + const initialPlaylist = currentPlaylist(); + currentPlaylist.set({ ...initialPlaylist, id: 'xtream-2' }); + resolveBatch(); + await pending; + + expect(savePlaybackPositionsBatch).toHaveBeenCalledWith( + 'xtream-1', + expect.anything() + ); + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + // The store now belongs to the other playlist — no stale refresh. + expect(loadAllPositions).not.toHaveBeenCalled(); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + currentPlaylist.set(initialPlaylist); + }); + + it('keeps rendered positions and reports the error when the season batch fails', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed')); + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const snackBar = TestBed.inject(MatSnackBar); + await playbackService.handleWatchToggleRequested( + { + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 1001, + nextPosition: { + playlistId: 'xtream-1', + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 1200, + durationSeconds: 1200, + }, + }, + ], + } as never, + 'season' + ); + + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'XTREAM.SEASON_WATCH_UPDATE_FAILED', + undefined, + { duration: 5000 } + ); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + consoleError.mockRestore(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts new file mode 100644 index 000000000..a093e78f9 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts @@ -0,0 +1,70 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; + +/** Stand-ins for the heavy children of the series-details page in specs. */ + +@Component({ + selector: 'app-season-container', + standalone: true, + changeDetection: ChangeDetectionStrategy.Eager, + template: '
', +}) +export class StubSeasonContainerComponent { + readonly seasons = input(null); + readonly seriesId = input(null); + readonly playlistId = input(''); + readonly seriesTitle = input(undefined); + readonly playbackPositions = input(null); + readonly downloadAdapter = input(null); + readonly downloadsEnabled = input(true); + readonly openingEpisodeId = input(null); + readonly activeEpisodeId = input(null); + readonly playingEpisodeId = input(null); + readonly seasonDescriptions = input(null); + readonly seasonPosters = input(null); + readonly seasonWatchBatchRunning = input(false); + readonly episodeClicked = output(); + readonly playbackToggleRequested = output(); + readonly seasonPlaybackToggleRequested = output(); +} + +@Component({ + selector: 'app-portal-inline-player', + standalone: true, + changeDetection: ChangeDetectionStrategy.Eager, + template: '', +}) +export class StubPortalInlinePlayerComponent { + readonly playbackSessionKey = input.required(); + readonly playback = input(null); + readonly episodeMetadata = input(null); + readonly seriesTitle = input(null); + readonly seriesNavigation = input(null); + readonly upNextEpisodes = input(null); + readonly seriesEpisodes = input(null); + readonly seasonPosters = input(null); + readonly episodePlaybackPositions = input(null); + readonly seasonLoadStates = input(null); + readonly timeUpdate = output(); + readonly closed = output(); + readonly streamUrlCopied = output(); + readonly externalFallbackRequested = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); + readonly upNextEpisodeSelected = output(); + readonly episodePanelSeasonSelected = output(); +} + +@Component({ + selector: 'mat-icon', + standalone: true, + changeDetection: ChangeDetectionStrategy.Eager, + template: '', +}) +export class StubMatIconComponent {} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-episode-playback.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-episode-playback.ts new file mode 100644 index 000000000..28766621f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-episode-playback.ts @@ -0,0 +1,58 @@ +import type { + PlayerContentInfo, + ResolvedPortalPlayback, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import { + resolveSeriesPlaybackEpisodeState, + type SeriesPlaybackEpisodeState, +} from '@iptvnator/ui/playback'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +export interface SerialEpisodePlaybackRequest { + readonly playlistId: string; + readonly selectedItem: XtreamSerieDetailsView; + readonly episode: XtreamSerieEpisode; + readonly streamUrl: string; + /** The episode's saved offset, when it has one. */ + readonly startTime: number | undefined; +} + +export interface SerialEpisodePlayback { + readonly playback: ResolvedPortalPlayback; + readonly episodeState: SeriesPlaybackEpisodeState | null; +} + +/** + * The playback an episode of the series on screen resolves to, and its place + * among the series' episodes for the inline player's navigation. + */ +export function buildSerialEpisodePlayback( + request: SerialEpisodePlaybackRequest +): SerialEpisodePlayback { + const { episode, selectedItem } = request; + const contentInfo: PlayerContentInfo = { + playlistId: request.playlistId, + contentXtreamId: Number(episode.id), + contentType: 'episode', + seriesXtreamId: Number(selectedItem.series_id), + seasonNumber: Number(episode.season), + episodeNumber: Number(episode.episode_num), + }; + + const playback: ResolvedPortalPlayback = { + streamUrl: request.streamUrl, + title: episode.title, + thumbnail: selectedItem.info.cover, + startTime: request.startTime, + contentInfo, + }; + + const episodeState = resolveSeriesPlaybackEpisodeState({ + episodesBySeason: selectedItem.episodes, + currentEpisodeId: episode.id, + fallbackSeasonNumber: Number(episode.season), + fallbackEpisodeNumber: Number(episode.episode_num), + }); + return { playback, episodeState }; +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.spec.ts new file mode 100644 index 000000000..aa6cc02c7 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.spec.ts @@ -0,0 +1,114 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { + injectXtreamDetailNavigation, + type XtreamDetailNavigation, +} from './xtream-detail-navigation'; + +describe('injectXtreamDetailNavigation', () => { + const route = { snapshot: { params: {} } }; + const navigate = jest.fn(); + const buildLink = jest.fn(); + const currentPlaylist = signal<{ id: string } | null>(null); + const enrichmentEnabled = signal(true); + + function create(mediaType: 'movie' | 'tv'): XtreamDetailNavigation { + return TestBed.runInInjectionContext(() => + injectXtreamDetailNavigation(mediaType) + ); + } + + beforeEach(() => { + navigate.mockReset(); + buildLink.mockReset(); + currentPlaylist.set({ id: 'xtream-1' }); + enrichmentEnabled.set(true); + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: { navigate } }, + { provide: ActivatedRoute, useValue: route }, + { provide: XtreamStore, useValue: { currentPlaylist } }, + { provide: CrossPortalSimilarService, useValue: { buildLink } }, + { + provide: TmdbEnrichmentService, + useValue: { isEnabled: () => enrichmentEnabled() }, + }, + ], + }); + }); + + it('opens the actor page of a TMDB-matched cast member', () => { + create('movie').openActor({ name: 'Mara', tmdbPersonId: 77 } as never); + + expect(navigate).toHaveBeenCalledWith([ + '/workspace/xtreams', + 'xtream-1', + 'actor', + 77, + ]); + }); + + it('opens no actor page without a playlist or a TMDB person', () => { + const navigation = create('movie'); + + navigation.openActor({ name: 'Mara' } as never); + currentPlaylist.set(null); + navigation.openActor({ name: 'Mara', tmdbPersonId: 77 } as never); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('opens a similar title of this playlist relative to the details route', () => { + create('tv').openSimilar({ categoryId: 12, id: 345 } as never); + + expect(navigate).toHaveBeenCalledWith(['../..', 12, 345], { + relativeTo: route, + }); + }); + + it('opens a similar title of another portal through its link', () => { + const item = { match: { playlistId: 'xtream-2' } } as never; + const link = ['/workspace/xtreams', 'xtream-2', 'series', '3', '9']; + buildLink.mockReturnValue(link); + + create('tv').openSimilarInPortals(item); + + expect(buildLink).toHaveBeenCalledWith(item); + expect(navigate).toHaveBeenCalledWith(link); + }); + + it.each(['movie', 'tv'] as const)( + 'sends the facet chips to the %s Discover page of the playlist', + (mediaType) => { + const { discover } = create(mediaType); + + expect(discover.canOpenYear('1999-03-31')).toBe(true); + discover.openYear('1999-03-31'); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'xtreams', 'xtream-1', 'discover'], + { queryParams: { type: mediaType, year: '1999' } } + ); + } + ); + + it('offers no facet chip while enrichment cannot fill Discover', () => { + const { discover } = create('movie'); + + enrichmentEnabled.set(false); + expect(discover.canOpenYear('1999-03-31')).toBe(false); + + enrichmentEnabled.set(true); + currentPlaylist.set(null); + expect(discover.canOpenYear('1999-03-31')).toBe(false); + discover.openYear('1999-03-31'); + + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.ts b/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.ts new file mode 100644 index 000000000..f0c2d93a2 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.ts @@ -0,0 +1,73 @@ +import { inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + createDiscoverFacetNavigation, + type DiscoverFacetNavigation, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarItem, + CrossPortalSimilarService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces'; +import type { SimilarCatalogItem } from './tmdb-similar.util'; + +/** Where an Xtream movie or series details page can send the viewer. */ +export interface XtreamDetailNavigation { + /** Clickable year/genre/country chips (Discover pages) */ + readonly discover: DiscoverFacetNavigation; + /** The actor page of a TMDB-matched cast member. */ + openActor(member: TmdbEnrichedCastMember): void; + /** A Similar-rail title of this playlist: the same route, another id. */ + openSimilar(item: SimilarCatalogItem): void; + /** A Similar-rail title found in another of the user's portals. */ + openSimilarInPortals(item: CrossPortalSimilarItem): void; +} + +/** + * Outbound navigation shared by the Xtream movie (`movie`) and series (`tv`) + * details pages. + * + * Must run in an injection context. + */ +export function injectXtreamDetailNavigation( + mediaType: 'movie' | 'tv' +): XtreamDetailNavigation { + const router = inject(Router); + const route = inject(ActivatedRoute); + const xtreamStore = inject(XtreamStore); + const crossPortalSimilar = inject(CrossPortalSimilarService); + const tmdbEnrichment = inject(TmdbEnrichmentService); + + return { + discover: createDiscoverFacetNavigation(() => { + const playlistId = xtreamStore.currentPlaylist()?.id; + // Discover reads its results from TMDB, so a chip must not offer a + // page that enrichment cannot fill + return playlistId && tmdbEnrichment.isEnabled() + ? { portal: 'xtream', mediaType, playlistId } + : null; + }), + openActor(member) { + const playlistId = xtreamStore.currentPlaylist()?.id; + if (!playlistId || !member.tmdbPersonId) { + return; + } + void router.navigate([ + '/workspace/xtreams', + playlistId, + 'actor', + member.tmdbPersonId, + ]); + }, + openSimilar(item) { + void router.navigate(['../..', item.categoryId, item.id], { + relativeTo: route, + }); + }, + openSimilarInPortals(item) { + void router.navigate(crossPortalSimilar.buildLink(item)); + }, + }; +}