diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 1513625dd..77e9599b5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -17,7 +17,7 @@ import { } from '@iptvnator/portal/stalker/data-access'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; -import { DownloadsService } from '@iptvnator/services'; +import { DownloadsService, TmdbEnrichmentService } from '@iptvnator/services'; import { EMPTY, of } from 'rxjs'; import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component'; import { StalkerSeriesViewComponent } from './stalker-series-view.component'; @@ -84,6 +84,7 @@ describe('StalkerSeriesViewComponent', () => { const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); const openResolvedPlayback = jest.fn(); const isEmbeddedPlayer = jest.fn(); + const tmdbGetSeason = jest.fn(); beforeEach(async () => { selectedContentType.set('series'); @@ -136,6 +137,11 @@ describe('StalkerSeriesViewComponent', () => { openResolvedPlayback.mockClear(); isEmbeddedPlayer.mockReset(); isEmbeddedPlayer.mockReturnValue(false); + tmdbGetSeason.mockReset(); + tmdbGetSeason.mockResolvedValue({ + overview: 'Season overview from TMDB', + episodes: [], + }); await TestBed.configureTestingModule({ imports: [StalkerSeriesViewComponent], @@ -190,6 +196,14 @@ describe('StalkerSeriesViewComponent', () => { startDownload: jest.fn(), }, }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => true, + getSeason: tmdbGetSeason, + getSeasonEpisodes: jest.fn().mockResolvedValue(null), + }, + }, { provide: MatSnackBar, useValue: { @@ -681,4 +695,36 @@ describe('StalkerSeriesViewComponent', () => { undefined ); }); + + it('fetches the TMDB season once the show-level match arrives after auto-select', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + // Season tabs auto-select immediately — usually before the async + // show-level enrichment has written tmdb_id. + const seasonContainer = fixture.debugElement.query( + By.directive(StubSeasonContainerComponent) + ).componentInstance as StubSeasonContainerComponent; + seasonContainer.seasonSelected.emit('1'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + // The TMDB match lands afterwards — the fetch must run now. + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 000ecfc38..d9624c85c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -7,6 +7,7 @@ import { input, output, signal, + untracked, } from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -139,6 +140,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { private readonly tmdbSeasonOverviews = signal< ReadonlyMap >(new Map()); + /** Season currently selected in the season container. */ + private readonly selectedSeasonKey = signal(null); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); /** Season descriptions for the season tabs (TMDB overview per season). */ @@ -185,6 +188,19 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading; constructor() { + // TMDB season fetch, keyed on (tmdb_id, selected season). With season + // tabs the first seasonSelected fires immediately when seasons load — + // usually BEFORE the async show-level TMDB enrichment has written + // tmdb_id — so the fetch must re-run when the match arrives, not only + // on selection. fetchTmdbSeason is idempotent per (tmdbId, season). + effect(() => { + const tmdbId = this.displayItem()?.info?.tmdb_id; + const seasonKey = this.selectedSeasonKey(); + if (tmdbId && seasonKey) { + untracked(() => void this.fetchTmdbSeason(seasonKey)); + } + }); + // Effect to load VOD series seasons when a VOD series item is selected effect(() => { if (this.isVodSeries()) { @@ -365,7 +381,9 @@ export class StalkerSeriesViewComponent implements OnDestroy { * For VOD Series, triggers lazy loading of episodes. */ onSeasonSelected(seasonKey: string) { - void this.fetchTmdbSeason(seasonKey); + // The TMDB fetch itself runs from the constructor effect keyed on + // (tmdb_id, selectedSeasonKey) — see the race note there. + this.selectedSeasonKey.set(seasonKey); if (!this.isVodSeries()) return; 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 16c9c7081..1c55380c4 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 @@ -7,6 +7,7 @@ import { OnDestroy, OnInit, signal, + untracked, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatIcon } from '@angular/material/icon'; @@ -109,6 +110,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata; readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation; + /** Season currently selected in the season container. */ + private readonly selectedSeasonKey = signal(null); + /** Season overviews from get_series_info, keyed by season key. */ readonly seasonDescriptions = computed>(() => { const descriptions: Record = {}; @@ -137,6 +141,22 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { constructor() { this.playback.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 — + // 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 & { @@ -246,7 +266,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { } onSeasonSelected(seasonKey: string): void { - this.xtreamStore.enrichSelectedSerialSeason(seasonKey); + // The enrichment call itself runs from the constructor effect keyed + // on (tmdb_id, selectedSeasonKey) — see the race note there. + this.selectedSeasonKey.set(seasonKey); } playEpisode(episode: XtreamSerieEpisode): void { diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss index 53b228802..b47fd72f1 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss @@ -90,6 +90,9 @@ } .shell__about { + display: block; + // Air between the last episode row and the section divider. + margin-top: 32px; animation: shell-fade-in 250ms ease 100ms both; }