fix(portals): re-run TMDB season enrichment when the show match arrives

With season tabs the first seasonSelected fires as soon as seasons
load — usually before the async show-level TMDB enrichment has written
tmdb_id — so the season fetch silently no-oped and was never retried,
leaving episode stills/plots and the season description empty. Both
detail hosts now key the fetch on (tmdb_id, selected season) in an
effect, so it runs whichever arrives last. Also adds breathing room
above the About divider in watch state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 20:05:29 +02:00
1 parent d05ee9eef7
commit 98e6bec35e
4 files changed
+92 -3

No files matched your search

@@ -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);
});
});
@@ -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<string, string>
>(new Map());
/** Season currently selected in the season container. */
private readonly selectedSeasonKey = signal<string | null>(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;
@@ -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<string | null>(null);
/** Season overviews from get_series_info, keyed by season key. */
readonly seasonDescriptions = computed<Record<string, string>>(() => {
const descriptions: Record<string, string> = {};
@@ -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 {
@@ -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;
}