refactor(stalker): extract series-view positions and watched toggles

stalker-series-view.component.ts grew from 1601 to 1813 counted lines and
its spec reached 1198 of 1200. Move code out verbatim:

- saved positions, their reconcile and the persist/clear writes go to the
  component-provided StalkerSeriesPositionsService; the ordering of reads
  and writes goes to StalkerSeriesPositionQueue
- episode, season and series watched toggles go to
  StalkerSeriesWatchToggleService, the batch core to
  runStalkerWatchToggleBatch
- the lazy VOD season loads go to StalkerVodSeasonEpisodeLoader

Every effect stays registered in the component constructor in its original
order; template bindings and public member names are unchanged.

The spec setup moves to a shared harness and the spillover-prefetch and
TMDB season cases to their own specs; the 184 cases of the directory are
unchanged.

Counted lines: component 1813 -> 1136, component spec 1198 -> 670.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-04 10:08:35 +02:00
1 parent 7a2e75606b
commit f70f679b9f
11 files changed
+1927 -1492

No files matched your search

@@ -0,0 +1,12 @@
/**
* The numeric series id of a Stalker item id: the part before the first `:`
* when the id carries a suffix. An empty or non-numeric id resolves to 0,
* which callers treat as "no series".
*/
export function toStalkerSeriesId(id: string | number): number {
const raw = String(id ?? '').trim();
if (!raw) return 0;
const primary = raw.includes(':') ? raw.split(':')[0] : raw;
const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
@@ -0,0 +1,187 @@
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
/** The playlist and series one generation of position reads and writes belongs to. */
export interface StalkerSeriesPositionContext {
readonly generation: number;
readonly playlistId: string;
readonly seriesXtreamId: number;
readonly mutationKey: string;
}
/** What the queue needs from the owner of the position rows. */
export interface StalkerSeriesPositionQueueHost {
/** Whether the page still shows the playlist and series of `context`. */
readonly isShown: (context: StalkerSeriesPositionContext) => boolean;
/** Reads the persisted position rows of `context`. */
readonly read: (
context: StalkerSeriesPositionContext
) => Promise<PlaybackPositionData[]>;
/** Takes over rows read for a context that is still the active one. */
readonly publish: (
context: StalkerSeriesPositionContext,
positions: PlaybackPositionData[]
) => void;
}
/**
* Orders the position reads and writes of the Stalker series view. Writes of
* one playlist/series run one after another; a read waits for the writes
* queued before it, and its answer is dropped once a newer read or write
* started or the page moved on to another series. A write that overtakes a
* read still in flight re-reads the rows after the queue drained.
*/
export class StalkerSeriesPositionQueue {
private activeContext: StalkerSeriesPositionContext | null = null;
private contextGeneration = 0;
private readonly mutationQueues = new Map<string, Promise<void>>();
private readonly pendingLoads = new Map<
StalkerSeriesPositionContext,
Set<number>
>();
private readonly reloadKeys = new Set<string>();
private loadGeneration = 0;
constructor(private readonly host: StalkerSeriesPositionQueueHost) {}
/** Makes `playlistId`/`seriesXtreamId` the series reads and writes apply to. */
activate(
playlistId: string,
seriesXtreamId: number
): StalkerSeriesPositionContext {
const context: StalkerSeriesPositionContext = {
generation: ++this.contextGeneration,
playlistId,
seriesXtreamId,
mutationKey: JSON.stringify([playlistId, seriesXtreamId]),
};
this.activeContext = context;
return context;
}
/** No series is shown: retires the active context and every read in flight. */
deactivate(): void {
this.activeContext = null;
this.contextGeneration++;
this.loadGeneration++;
}
isActive(context: StalkerSeriesPositionContext): boolean {
const activeContext = this.activeContext;
return (
activeContext === context &&
activeContext.generation === context.generation &&
this.host.isShown(context)
);
}
/** The active context when it belongs to `playlistId` (and `seriesXtreamId`, if given). */
mutationContext(
playlistId: string,
seriesXtreamId?: number | null
): StalkerSeriesPositionContext | null {
const context = this.activeContext;
if (
!context ||
context.playlistId !== playlistId ||
(seriesXtreamId != null &&
context.seriesXtreamId !== seriesXtreamId)
) {
return null;
}
return context;
}
async load(context: StalkerSeriesPositionContext): Promise<void> {
const generation = ++this.loadGeneration;
this.trackPendingLoad(context, generation);
try {
await this.waitForMutations(context.mutationKey);
if (generation !== this.loadGeneration || !this.isActive(context)) {
return;
}
const positions = await this.host.read(context);
if (generation !== this.loadGeneration || !this.isActive(context)) {
return;
}
this.host.publish(context, positions);
} finally {
this.untrackPendingLoad(context, generation);
}
}
enqueue(
context: StalkerSeriesPositionContext,
operation: () => Promise<void>
): Promise<void> {
if (this.hasCurrentPendingLoad(context)) {
this.reloadKeys.add(context.mutationKey);
}
this.loadGeneration++;
const previous = this.waitForMutations(context.mutationKey);
const result = previous.then(operation);
const barrier = result.then(
() => undefined,
() => undefined
);
this.mutationQueues.set(context.mutationKey, barrier);
void barrier.then(() => {
if (this.mutationQueues.get(context.mutationKey) === barrier) {
this.mutationQueues.delete(context.mutationKey);
this.reloadAfterMutations(context.mutationKey);
}
});
return result;
}
private waitForMutations(mutationKey: string): Promise<void> {
return this.mutationQueues.get(mutationKey) ?? Promise.resolve();
}
private trackPendingLoad(
context: StalkerSeriesPositionContext,
generation: number
): void {
const generations = this.pendingLoads.get(context) ?? new Set<number>();
generations.add(generation);
this.pendingLoads.set(context, generations);
}
private untrackPendingLoad(
context: StalkerSeriesPositionContext,
generation: number
): void {
const generations = this.pendingLoads.get(context);
generations?.delete(generation);
if (generations?.size === 0) {
this.pendingLoads.delete(context);
}
}
private hasCurrentPendingLoad(
context: StalkerSeriesPositionContext
): boolean {
return Boolean(
this.pendingLoads.get(context)?.has(this.loadGeneration)
);
}
private reloadAfterMutations(mutationKey: string): void {
if (!this.reloadKeys.delete(mutationKey)) {
return;
}
const context = this.activeContext;
if (
!context ||
context.mutationKey !== mutationKey ||
!this.isActive(context) ||
this.hasCurrentPendingLoad(context)
) {
return;
}
void this.load(context);
}
}
@@ -0,0 +1,331 @@
import { Injectable, Signal, inject, signal, untracked } from '@angular/core';
import {
PORTAL_PLAYBACK_POSITIONS,
createLogger,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
type StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
import type {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import { toStalkerSeriesId } from './stalker-series-id';
import {
clearStalkerSeriesPosition,
reconcileStalkerSeriesPositions,
saveStalkerSeriesPosition,
StalkerSeriesPositionPartialSaveError,
} from './stalker-series-position-compatibility';
import { StalkerSeriesPositionQueue } from './stalker-series-position-queue';
interface StalkerSeriesPositionsBindings {
readonly displayItem: Signal<StalkerSelectedVodItem | null>;
readonly mappedSeasons: Signal<Record<string, XtreamSerieEpisode[]>>;
}
/**
* Saved episode positions of the series the Stalker series view shows: the
* rows read from the repository, their mapping onto the episodes on the page
* (scoped rows plus compatible legacy promotions) and the writes that keep
* both in step. Component-scoped (provide it in the component's `providers`).
*/
@Injectable()
export class StalkerSeriesPositionsService {
private readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly migrationPlaybackPositions = {
savePlaybackPosition: (
playlistId: string,
data: PlaybackPositionData
) =>
this.playbackPositions.savePlaybackPositionOrThrow(
playlistId,
data
),
clearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) =>
this.playbackPositions.clearPlaybackPositionOrThrow(
playlistId,
contentXtreamId,
contentType
),
};
private readonly logger = createLogger('StalkerSeriesView');
private readonly bindings = signal<StalkerSeriesPositionsBindings | null>(
null
);
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
readonly rawSeriesPositions = signal<readonly PlaybackPositionData[]>([]);
/**
* `playlistId:seriesId` once the persisted positions for the shown
* series have been READ (a failed read leaves it null): the dashboard
* resume handoff must not start an episode from the beginning because
* the offsets have not arrived yet.
*/
readonly seriesPositionsLoadedKey = signal<string | null>(null);
private readonly legacyPositionByTrackingId = signal<
Map<number, PlaybackPositionData>
>(new Map());
private readonly queue = new StalkerSeriesPositionQueue({
isShown: (context) =>
this.stalkerStore.currentPlaylist()?._id === context.playlistId &&
this.shownSeriesId() === context.seriesXtreamId,
read: (context) =>
this.playbackPositions.getSeriesPlaybackPositions(
context.playlistId,
context.seriesXtreamId
),
publish: (context, positions) => {
this.rawSeriesPositions.set(positions);
this.seriesPositionsLoadedKey.set(
this.seriesPositionsKey(
context.playlistId,
context.seriesXtreamId
)
);
},
});
bind(bindings: StalkerSeriesPositionsBindings): void {
this.bindings.set(bindings);
}
/**
* Starts over for the series on screen: drops what the previous series
* left behind and reads the persisted rows. Reads the shown item and the
* playlist, so an `effect` calling it re-runs when either changes.
*/
loadShownSeries(): void {
const item = this.displayItem();
const playlist = this.stalkerStore.currentPlaylist();
const normalizedSeriesId = toStalkerSeriesId(item?.id ?? 0);
if (item && playlist?._id && normalizedSeriesId > 0) {
this.logger.debug('Loading positions for series', {
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
});
this.rawSeriesPositions.set([]);
this.seriesPositionsLoadedKey.set(null);
this.episodePlaybackPositions.set(new Map());
this.legacyPositionByTrackingId.set(new Map());
const context = this.queue.activate(
playlist._id,
normalizedSeriesId
);
void this.queue.load(context);
} else {
this.queue.deactivate();
}
}
/**
* Maps the raw series position rows onto the currently mapped episodes
* (scoped rows plus compatible legacy promotions). Runs reactively from
* the component's constructor effect, and synchronously from the
* series-level watch toggle right after it hydrates lazy seasons — the
* effect only re-runs on the next change-detection tick, and enqueuing
* the batch against the stale maps would miss the hydrated episodes'
* legacy rows (an unwatch would leave rows behind that a later reconcile
* resurrects as watched).
*/
applyReconciledSeriesPositions(): void {
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesXtreamId = toStalkerSeriesId(item?.id ?? 0);
const rawSeriesPositions = this.rawSeriesPositions();
const episodesBySeason = this.bindings()?.mappedSeasons() ?? {};
if (!item || !playlistId || seriesXtreamId <= 0) {
if (rawSeriesPositions.length > 0) {
this.rawSeriesPositions.set([]);
}
if (this.episodePlaybackPositions().size > 0) {
this.episodePlaybackPositions.set(new Map());
}
if (this.legacyPositionByTrackingId().size > 0) {
this.legacyPositionByTrackingId.set(new Map());
}
return;
}
const reconciled = reconcileStalkerSeriesPositions({
seriesXtreamId,
episodesBySeason,
seriesPositions: rawSeriesPositions,
});
if (
rawSeriesPositions.length === 0 &&
reconciled.positionsByTrackingId.size === 0 &&
untracked(() => this.episodePlaybackPositions().size) > 0
) {
return;
}
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
}
seriesPositionsKey(playlistId: string, seriesXtreamId: number): string {
return `${playlistId}:${seriesXtreamId}`;
}
persistSeriesPosition(
playlistId: string,
position: PlaybackPositionData
): Promise<void> {
const context = this.queue.mutationContext(
playlistId,
position.seriesXtreamId
);
if (!context) {
return Promise.resolve();
}
const legacyPosition = this.legacyPositionByTrackingId().get(
position.contentXtreamId
);
return this.queue.enqueue(context, async () => {
let clearedLegacy: boolean;
try {
clearedLegacy = await saveStalkerSeriesPosition({
repository: this.migrationPlaybackPositions,
playlistId,
position,
legacyPosition,
});
} catch (error) {
if (
error instanceof StalkerSeriesPositionPartialSaveError &&
this.queue.isActive(context)
) {
this.publishSavedSeriesPosition(
position,
legacyPosition,
false
);
}
throw error;
}
if (!this.queue.isActive(context)) {
return;
}
this.publishSavedSeriesPosition(
position,
legacyPosition,
clearedLegacy
);
});
}
clearSeriesPosition(
playlistId: string,
contentXtreamId: number
): Promise<void> {
const context = this.queue.mutationContext(playlistId);
if (!context) {
return Promise.resolve();
}
const position = this.episodePlaybackPositions().get(
contentXtreamId
) ?? {
contentXtreamId,
contentType: 'episode',
positionSeconds: 0,
playlistId,
seriesXtreamId: context.seriesXtreamId,
};
const legacyPosition =
this.legacyPositionByTrackingId().get(contentXtreamId);
return this.queue.enqueue(context, async () => {
const clearedLegacy = await clearStalkerSeriesPosition({
repository: this.migrationPlaybackPositions,
playlistId,
position,
legacyPosition,
});
if (!this.queue.isActive(context)) {
return;
}
this.publishClearedSeriesPosition(
contentXtreamId,
legacyPosition,
clearedLegacy
);
});
}
private displayItem(): StalkerSelectedVodItem | null {
return this.bindings()?.displayItem() ?? null;
}
private shownSeriesId(): number {
return toStalkerSeriesId(this.displayItem()?.id ?? 0);
}
private publishSavedSeriesPosition(
position: PlaybackPositionData,
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([position.contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(this.legacyPositionByTrackingId());
legacyPositions.delete(position.contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
this.rawSeriesPositions.set([
...this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(candidate.contentXtreamId)
),
position,
]);
this.updateEpisodePlaybackPosition(position);
}
private publishClearedSeriesPosition(
contentXtreamId: number,
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(this.legacyPositionByTrackingId());
legacyPositions.delete(contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
this.rawSeriesPositions.set(
this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(candidate.contentXtreamId)
)
);
this.removeEpisodePlaybackPosition(contentXtreamId);
}
private updateEpisodePlaybackPosition(
position: PlaybackPositionData
): void {
const updated = new Map(this.episodePlaybackPositions());
updated.set(position.contentXtreamId, position);
this.episodePlaybackPositions.set(updated);
}
private removeEpisodePlaybackPosition(contentXtreamId: number): void {
const updated = new Map(this.episodePlaybackPositions());
updated.delete(contentXtreamId);
this.episodePlaybackPositions.set(updated);
}
}
@@ -1,54 +1,30 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SeasonContainerComponent } from '@iptvnator/ui/components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { 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';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
import {
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
} from './stalker-series-view.test-helpers';
describe('StalkerSeriesViewComponent', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const selectedContentType = signal<'series' | 'vod'>('series');
const selectedItem = signal<StalkerVodSource | null>(null);
const serialSeasonsResource = signal<unknown[]>([]);
const vodSeriesSeasonsResource = signal<unknown[]>([]);
const isSerialSeasonsLoading = signal(false);
const fetchVodSeriesEpisodes = jest.fn();
const resolveVodPlayback = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
const openResolvedPlayback = jest.fn();
const openExternalPlayback = jest.fn();
const isEmbeddedPlayer = jest.fn();
const tmdbGetSeason = jest.fn();
const fetchLinkToPlay = jest.fn();
const currentPlaylist = signal({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
const {
selectedContentType,
selectedItem,
serialSeasonsResource,
vodSeriesSeasonsResource,
fetchVodSeriesEpisodes,
resolveVodPlayback,
openResolvedPlayback,
openExternalPlayback,
isEmbeddedPlayer,
fetchLinkToPlay,
currentPlaylist,
createFixture,
} = createStalkerSeriesViewHarness(() => jest.fn());
async function stabilize(): Promise<void> {
fixture.detectChanges();
@@ -63,184 +39,7 @@ describe('StalkerSeriesViewComponent', () => {
}
beforeEach(async () => {
selectedContentType.set('series');
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
},
});
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
vodSeriesSeasonsResource.set([]);
isSerialSeasonsLoading.set(false);
fetchVodSeriesEpisodes.mockReset();
resolveVodPlayback.mockReset();
resolveVodPlayback.mockImplementation(
async (
_cmd?: string,
title?: string,
thumbnail?: string,
_episodeNum?: number,
episodeId?: number,
startTime?: number
) => ({
streamUrl: 'http://stalker.example/episode.mpg',
title: title ?? 'Regular Series',
thumbnail: thumbnail ?? 'poster.jpg',
startTime,
contentInfo: {
playlistId: 'stalker-1',
contentXtreamId:
episodeId ?? Number(selectedItem()?.id ?? 0),
contentType: episodeId ? 'episode' : 'vod',
seriesXtreamId: episodeId
? Number(selectedItem()?.id ?? 0)
: undefined,
},
})
);
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
openResolvedPlayback.mockClear();
openExternalPlayback.mockReset();
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
tmdbGetSeason.mockReset();
tmdbGetSeason.mockResolvedValue({
overview: 'Season overview from TMDB',
episodes: [],
});
fetchLinkToPlay
.mockReset()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
currentPlaylist.set({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
providers: [
{
provide: StalkerStore,
useValue: {
selectedItem,
selectedContentType,
currentPlaylist,
getSerialSeasonsResource: () => serialSeasonsResource(),
getVodSeriesSeasonsResource: () =>
vodSeriesSeasonsResource(),
isVodSeriesSeasonsLoading: signal(false),
isSerialSeasonsLoading,
fetchVodSeriesEpisodes,
resolveVodPlayback,
fetchLinkToPlay,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions,
savePlaybackPosition: jest.fn(),
clearPlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer,
openResolvedPlayback,
openExternalPlayback,
},
},
{
provide: Router,
useValue: {
navigateByUrl: jest.fn(),
},
},
{
provide: TmdbEnrichmentService,
useValue: {
isEnabled: () => true,
getSeason: tmdbGetSeason,
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
currentLang: 'en',
defaultLang: 'en',
// EMPTY (not of(null)): the real TranslatePipe inside
// the detail shell reads `event.lang` from emissions.
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
],
})
.overrideComponent(StalkerSeriesViewComponent, {
remove: {
imports: [
FavoritesButtonComponent,
PortalInlinePlayerComponent,
SeasonContainerComponent,
TranslatePipe,
],
},
add: {
imports: [
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(
value: string | null | undefined,
params?: Record<string, number>
) => {
if (value === 'XTREAM.PLAY_EPISODE') {
return `Play episode ${
params?.['episode'] ?? '{{episode}}'
}`;
}
return value ?? '';
}
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerSeriesViewComponent);
fixture = await createFixture();
});
afterEach(() => {
@@ -783,10 +582,12 @@ describe('StalkerSeriesViewComponent', () => {
}),
})
);
// Prettier expands these compact fixtures enough to breach the spec's
// hard 1,200-line lint limit.
// prettier-ignore
expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', seasonNumber: 1, episodeNumber: 1 });
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Pilot',
seasonNumber: 1,
episodeNumber: 1,
});
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: false,
canNext: true,
@@ -834,251 +635,6 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('prefetches the next unopened season so the Up Next rail can spill over', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
// Season 1 loaded, season 2 still empty — the state a user is in
// right after opening the series and starting the first episode.
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
const firstEpisode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.onEpisodeClicked(firstEpisode);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
'50001',
'season-2'
);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E01',
'S02E01',
]);
});
it('does not re-request a spillover season that came back empty', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
// Failed or genuinely empty season: isLoading returns to false while
// episodes stays empty — the retry trap.
fetchVodSeriesEpisodes.mockResolvedValue([]);
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Further playback activity in the same season must not retrigger it.
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
});
it('retries a failed spillover prefetch on the next episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockRejectedValueOnce(new Error('network'));
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
// The failure must not loop while the same episode keeps playing.
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Moving to the next episode gives the transient failure a new chance.
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E02',
'S02E01',
]);
});
it('loads the next unloaded VOD-series season after the loaded season is watched', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -1180,162 +736,4 @@ describe('StalkerSeriesViewComponent', () => {
undefined
);
});
it('fetches the TMDB season once the show-level match arrives after auto-select', async () => {
await stabilize();
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');
await stabilize();
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);
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1);
});
it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => {
serialSeasonsResource.set([]);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
// Season resource still loading — fetching now would pass a zero
// season count, suppress the title-marker override and cache the
// wrong season forever (fetchSeason is idempotent).
expect(tmdbGetSeason).not.toHaveBeenCalled();
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
await stabilize();
// Single-season slice whose provider season is renumbered to 1:
// the title marker names the real TMDB season.
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => {
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
await stabilize();
// No show-level TMDB match yet — nothing fetched for the first item
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Detail-to-detail navigation reuses the component; the new item's
// TMDB match can arrive while the season resource reloads and the
// map still shows the previous series' seasons.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30002',
cmd: '/media/file_30002.mpg',
info: {
name: 'Other Series (2 season)',
description: 'Other description',
movie_image: 'poster2.jpg',
tmdb_id: 888,
},
} as never);
await stabilize();
// The new tmdb_id must NOT pair with the previous series' season
// context while the resource reloads.
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Once the new item's own seasons land, the fetch runs without a
// stale-season fetch. The container selects the corrected key once
// the new season map is rendered.
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30002.mpg',
series: [1, 2],
},
]);
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2);
});
it('enriches after equal-id navigation once the season resource settles', async () => {
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
await stabilize();
// Distinct items can reuse a provider id; the loading gate (not an
// id comparison) keeps the stale map from being used.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Different Series (3 season)',
description: 'Different description',
movie_image: 'poster3.jpg',
tmdb_id: 999,
},
} as never);
await stabilize();
expect(tmdbGetSeason).not.toHaveBeenCalled();
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('3');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3);
});
it('reads the season marker from o_name when name is generic', async () => {
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
o_name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
});
@@ -0,0 +1,281 @@
import { ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
import { StubPortalInlinePlayerComponent } from './stalker-series-view.test-helpers';
/**
* The inline player's "Up Next" rail across lazy Ministra VOD-series seasons:
* the season after the playing one is fetched ahead so the rail can spill
* over into it.
*/
describe('StalkerSeriesViewComponent Up Next spillover prefetch', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const {
selectedContentType,
selectedItem,
serialSeasonsResource,
vodSeriesSeasonsResource,
fetchVodSeriesEpisodes,
isEmbeddedPlayer,
createFixture,
} = createStalkerSeriesViewHarness(() => jest.fn());
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
}
beforeEach(async () => {
fixture = await createFixture();
});
afterEach(() => {
fixture?.destroy();
});
it('prefetches the next unopened season so the Up Next rail can spill over', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
// Season 1 loaded, season 2 still empty — the state a user is in
// right after opening the series and starting the first episode.
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
const firstEpisode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.onEpisodeClicked(firstEpisode);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
'50001',
'season-2'
);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E01',
'S02E01',
]);
});
it('does not re-request a spillover season that came back empty', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
// Failed or genuinely empty season: isLoading returns to false while
// episodes stays empty — the retry trap.
fetchVodSeriesEpisodes.mockResolvedValue([]);
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Further playback activity in the same season must not retrigger it.
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
});
it('retries a failed spillover prefetch on the next episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockRejectedValueOnce(new Error('network'));
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
// The failure must not loop while the same episode keeps playing.
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Moving to the next episode gives the transient failure a new chance.
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E02',
'S02E01',
]);
});
});
@@ -0,0 +1,273 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SeasonContainerComponent } from '@iptvnator/ui/components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { 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';
import {
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
} from './stalker-series-view.test-helpers';
/**
* The slice of a Jest mock the harness drives. This file is part of the
* library's own type-check, which has no Jest globals, so a spec hands in
* the factory (`() => jest.fn()`) and gets its own mock type back.
*/
export interface StalkerSeriesViewTestMock {
mockReset(): this;
mockClear(): this;
mockReturnValue(value: unknown): this;
mockResolvedValue(value: unknown): this;
mockImplementation(implementation: (...args: never[]) => unknown): this;
}
/**
* The TestBed the Stalker series-view component specs share: the store
* signals and collaborator mocks a case drives, and `createFixture()`, which
* puts them back to the state every case starts from (a regular series with
* one two-episode season, no embedded player) before building the component
* with its stubbed children.
*
* Call it once per `describe`; the mocks live as long as that block.
*/
export function createStalkerSeriesViewHarness<
TMock extends StalkerSeriesViewTestMock,
>(createMock: () => TMock) {
const selectedContentType = signal<'series' | 'vod'>('series');
const selectedItem = signal<StalkerVodSource | null>(null);
const serialSeasonsResource = signal<unknown[]>([]);
const vodSeriesSeasonsResource = signal<unknown[]>([]);
const isSerialSeasonsLoading = signal(false);
const fetchVodSeriesEpisodes = createMock();
const resolveVodPlayback = createMock();
const getSeriesPlaybackPositions = createMock().mockResolvedValue([]);
const openResolvedPlayback = createMock();
const openExternalPlayback = createMock();
const isEmbeddedPlayer = createMock();
const tmdbGetSeason = createMock();
const fetchLinkToPlay = createMock();
const currentPlaylist = signal({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
async function createFixture(): Promise<
ComponentFixture<StalkerSeriesViewComponent>
> {
selectedContentType.set('series');
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
},
});
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
vodSeriesSeasonsResource.set([]);
isSerialSeasonsLoading.set(false);
fetchVodSeriesEpisodes.mockReset();
resolveVodPlayback.mockReset();
resolveVodPlayback.mockImplementation(
async (
_cmd?: string,
title?: string,
thumbnail?: string,
_episodeNum?: number,
episodeId?: number,
startTime?: number
) => ({
streamUrl: 'http://stalker.example/episode.mpg',
title: title ?? 'Regular Series',
thumbnail: thumbnail ?? 'poster.jpg',
startTime,
contentInfo: {
playlistId: 'stalker-1',
contentXtreamId:
episodeId ?? Number(selectedItem()?.id ?? 0),
contentType: episodeId ? 'episode' : 'vod',
seriesXtreamId: episodeId
? Number(selectedItem()?.id ?? 0)
: undefined,
},
})
);
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
openResolvedPlayback.mockClear();
openExternalPlayback.mockReset();
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
tmdbGetSeason.mockReset();
tmdbGetSeason.mockResolvedValue({
overview: 'Season overview from TMDB',
episodes: [],
});
fetchLinkToPlay
.mockReset()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
currentPlaylist.set({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
providers: [
{
provide: StalkerStore,
useValue: {
selectedItem,
selectedContentType,
currentPlaylist,
getSerialSeasonsResource: () => serialSeasonsResource(),
getVodSeriesSeasonsResource: () =>
vodSeriesSeasonsResource(),
isVodSeriesSeasonsLoading: signal(false),
isSerialSeasonsLoading,
fetchVodSeriesEpisodes,
resolveVodPlayback,
fetchLinkToPlay,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions,
savePlaybackPosition: createMock(),
clearPlaybackPosition: createMock(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer,
openResolvedPlayback,
openExternalPlayback,
},
},
{
provide: Router,
useValue: {
navigateByUrl: createMock(),
},
},
{
provide: TmdbEnrichmentService,
useValue: {
isEnabled: () => true,
getSeason: tmdbGetSeason,
},
},
{
provide: MatSnackBar,
useValue: {
open: createMock(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
currentLang: 'en',
defaultLang: 'en',
// EMPTY (not of(null)): the real TranslatePipe inside
// the detail shell reads `event.lang` from emissions.
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
],
})
.overrideComponent(StalkerSeriesViewComponent, {
remove: {
imports: [
FavoritesButtonComponent,
PortalInlinePlayerComponent,
SeasonContainerComponent,
TranslatePipe,
],
},
add: {
imports: [
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(
value: string | null | undefined,
params?: Record<string, number>
) => {
if (value === 'XTREAM.PLAY_EPISODE') {
return `Play episode ${
params?.['episode'] ?? '{{episode}}'
}`;
}
return value ?? '';
}
),
],
},
})
.compileComponents();
return TestBed.createComponent(StalkerSeriesViewComponent);
}
return {
selectedContentType,
selectedItem,
serialSeasonsResource,
vodSeriesSeasonsResource,
isSerialSeasonsLoading,
fetchVodSeriesEpisodes,
resolveVodPlayback,
getSeriesPlaybackPositions,
openResolvedPlayback,
openExternalPlayback,
isEmbeddedPlayer,
tmdbGetSeason,
fetchLinkToPlay,
currentPlaylist,
createFixture,
};
}
@@ -0,0 +1,192 @@
import { ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
import { StubSeasonContainerComponent } from './stalker-series-view.test-helpers';
/**
* The lazy TMDB season fetch of the Stalker series view: when it runs
* relative to the show-level TMDB match and the season resource, and which
* TMDB season it asks for.
*/
describe('StalkerSeriesViewComponent TMDB season fetch', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const {
selectedItem,
serialSeasonsResource,
isSerialSeasonsLoading,
tmdbGetSeason,
createFixture,
} = createStalkerSeriesViewHarness(() => jest.fn());
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
}
beforeEach(async () => {
fixture = await createFixture();
});
afterEach(() => {
fixture?.destroy();
});
it('fetches the TMDB season once the show-level match arrives after auto-select', async () => {
await stabilize();
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');
await stabilize();
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);
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1);
});
it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => {
serialSeasonsResource.set([]);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
// Season resource still loading — fetching now would pass a zero
// season count, suppress the title-marker override and cache the
// wrong season forever (fetchSeason is idempotent).
expect(tmdbGetSeason).not.toHaveBeenCalled();
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
await stabilize();
// Single-season slice whose provider season is renumbered to 1:
// the title marker names the real TMDB season.
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => {
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
await stabilize();
// No show-level TMDB match yet — nothing fetched for the first item
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Detail-to-detail navigation reuses the component; the new item's
// TMDB match can arrive while the season resource reloads and the
// map still shows the previous series' seasons.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30002',
cmd: '/media/file_30002.mpg',
info: {
name: 'Other Series (2 season)',
description: 'Other description',
movie_image: 'poster2.jpg',
tmdb_id: 888,
},
} as never);
await stabilize();
// The new tmdb_id must NOT pair with the previous series' season
// context while the resource reloads.
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Once the new item's own seasons land, the fetch runs without a
// stale-season fetch. The container selects the corrected key once
// the new season map is rendered.
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30002.mpg',
series: [1, 2],
},
]);
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2);
});
it('enriches after equal-id navigation once the season resource settles', async () => {
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
await stabilize();
// Distinct items can reuse a provider id; the loading gate (not an
// id comparison) keeps the stale map from being used.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Different Series (3 season)',
description: 'Different description',
movie_image: 'poster3.jpg',
tmdb_id: 999,
},
} as never);
await stabilize();
expect(tmdbGetSeason).not.toHaveBeenCalled();
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('3');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3);
});
it('reads the season marker from o_name when name is generic', async () => {
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
o_name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
});
@@ -0,0 +1,113 @@
import type { Logger } from '@iptvnator/portal/shared/util';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import type { SeasonContainerSeriesPlaybackToggleRequest } from '@iptvnator/ui/components';
import { StalkerSeriesPositionPartialSaveError } from './stalker-series-position-compatibility';
export interface StalkerWatchToggleFeedback {
readonly marked: string;
readonly unmarked: string;
readonly partialMarked: string;
readonly partialUnmarked: string;
readonly failed: string;
}
// The marked and partial keys are scope-generic on purpose ("{{count}}
// episodes marked as watched", "{{count}} marked · {{failed}} failed");
// only unmark-success and failure name their scope.
export const SEASON_WATCH_FEEDBACK: StalkerWatchToggleFeedback = {
marked: 'XTREAM.SEASON_MARKED_WATCHED',
unmarked: 'XTREAM.SEASON_MARKED_UNWATCHED',
partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL',
partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
failed: 'XTREAM.SEASON_WATCH_UPDATE_FAILED',
};
export const SERIES_WATCH_FEEDBACK: StalkerWatchToggleFeedback = {
marked: 'XTREAM.SEASON_MARKED_WATCHED',
unmarked: 'XTREAM.SERIES_MARKED_UNWATCHED',
partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL',
partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
failed: 'XTREAM.SERIES_WATCH_UPDATE_FAILED',
};
/** One watched/unwatched batch and what it needs from the page running it. */
export interface StalkerWatchToggleBatch {
readonly request: SeasonContainerSeriesPlaybackToggleRequest;
readonly playlistId: string;
/** Whether the page still shows the series the batch was started on. */
readonly stillCurrent: () => boolean;
readonly feedback: StalkerWatchToggleFeedback;
readonly persist: (
playlistId: string,
position: PlaybackPositionData
) => Promise<void>;
readonly clear: (
playlistId: string,
contentXtreamId: number
) => Promise<void>;
/** Re-reads the catalog grid's progress badges; must not reject. */
readonly refreshCatalogPositions: (
playlistId: string
) => Promise<void> | undefined;
/** Shows the translated feedback for `key`. */
readonly notify: (key: string, params?: object) => void;
readonly logger: Logger;
}
export async function runStalkerWatchToggleBatch(
batch: StalkerWatchToggleBatch
): Promise<void> {
const { request, playlistId, feedback } = batch;
// Enqueue every episode synchronously: each mutation chains on
// the previous one's never-rejecting barrier, so the queue
// serializes the writes (incl. per-episode legacy-row cleanup)
// and reloads positions once after the whole chain drains.
const outcomes = await Promise.all(
request.requests.map((item) =>
(item.nextPosition
? batch.persist(playlistId, item.nextPosition)
: batch.clear(playlistId, item.contentXtreamId)
).then(
() => true,
// The scoped watched row was saved and published —
// only the legacy-row cleanup failed. The episode IS
// watched, so it must not count against the batch.
(error: unknown) =>
error instanceof StalkerSeriesPositionPartialSaveError
)
)
);
const failed = outcomes.filter((ok) => !ok).length;
const succeeded = outcomes.length - failed;
if (failed > 0) {
batch.logger.error(
`Watched toggle: ${failed} of ${outcomes.length} episodes failed`
);
}
if (succeeded > 0) {
// Partial successes changed rows too — the catalog badge
// must follow even when the user already moved on. A failed
// refresh must not break the feedback flow below.
await batch.refreshCatalogPositions(playlistId);
}
if (!batch.stillCurrent()) {
return;
}
if (failed === 0) {
batch.notify(
request.markWatched ? feedback.marked : feedback.unmarked,
{ count: succeeded }
);
} else if (succeeded > 0) {
batch.notify(
request.markWatched
? feedback.partialMarked
: feedback.partialUnmarked,
{ count: succeeded, failed }
);
} else {
batch.notify(feedback.failed);
}
}
@@ -0,0 +1,323 @@
import { Injectable, Signal, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
createLogger,
isPortalPlaybackWatched,
} from '@iptvnator/portal/shared/util';
import {
isVodSeasonHydrationPending,
StalkerStore,
type StalkerSelectedVodItem,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import {
buildSeriesWatchToggleRequest,
type SeasonContainerPlaybackToggleRequest,
type SeasonContainerSeasonPlaybackToggleRequest,
type SeasonContainerSeriesPlaybackToggleRequest,
} from '@iptvnator/ui/components';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { toStalkerSeriesId } from './stalker-series-id';
import { StalkerSeriesPositionsService } from './stalker-series-positions.service';
import {
runStalkerWatchToggleBatch,
SEASON_WATCH_FEEDBACK,
SERIES_WATCH_FEEDBACK,
type StalkerWatchToggleFeedback,
} from './stalker-series-watch-batch';
interface StalkerSeriesWatchToggleBindings {
readonly displayItem: Signal<StalkerSelectedVodItem | null>;
/** `playlist:series` of the series on screen. */
readonly currentSeriesKey: Signal<string>;
readonly isVodSeries: Signal<boolean>;
readonly vodSeriesSeasons: Signal<VodSeriesSeasonVm[]>;
readonly mappedSeasons: Signal<Record<string, XtreamSerieEpisode[]>>;
/** Episodes playing or launching, which a series toggle never marks. */
readonly excludedEpisodeIds: () => ReadonlySet<number>;
/** Resolves true when the portal answered, false when the request failed. */
readonly loadEpisodesForSeason: (
season: VodSeriesSeasonVm
) => Promise<boolean>;
}
/**
* Watched toggles of the Stalker series view: one episode, a season or the
* whole series (which also backs "Reset progress"). Component-scoped
* (provide it in the component's `providers`).
*/
@Injectable()
export class StalkerSeriesWatchToggleService {
private readonly stalkerStore = inject(StalkerStore);
private readonly positions = inject(StalkerSeriesPositionsService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
// Optional: absent in collection-detail mounts outside the catalog.
private readonly catalogFacade = inject(StalkerCatalogFacadeService, {
optional: true,
});
private readonly logger = createLogger('StalkerSeriesView');
private readonly bindings = signal<StalkerSeriesWatchToggleBindings | null>(
null
);
readonly seasonWatchBatchRunning = signal(false);
/**
* The episode chosen while a watched/reset batch still rewrote the rows
* a start resumes from; the last choice plays once the batch settled,
* and only on the series it was made for.
*/
private choiceHeldForBatch: {
readonly seriesKey: string;
readonly play: () => void;
} | null = null;
bind(bindings: StalkerSeriesWatchToggleBindings): void {
this.bindings.set(bindings);
}
/** Holds `play` until the running batch settled, replacing an earlier choice. */
holdChoice(play: () => void): void {
this.choiceHeldForBatch = {
seriesKey: this.currentSeriesKey(),
play,
};
}
async handlePlaybackToggleRequested(
request: SeasonContainerPlaybackToggleRequest
): Promise<void> {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) {
return;
}
if (request.nextPosition) {
await this.positions.persistSeriesPosition(
playlistId,
request.nextPosition
);
} else {
await this.positions.clearSeriesPosition(
playlistId,
request.contentXtreamId
);
}
// Keep the catalog grid's progress badge in sync (ownership-checked
// inside the facade; no-op outside the catalog context). A failed
// refresh keeps the cache populated-but-stale.
await this.catalogFacade
?.refreshPositions(playlistId)
.catch((error: unknown) =>
this.logger.warn('Catalog position refresh failed', error)
);
}
async handleSeasonPlaybackToggleRequested(
request: SeasonContainerSeasonPlaybackToggleRequest
): Promise<void> {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (
!playlistId ||
request.requests.length === 0 ||
this.seasonWatchBatchRunning()
) {
return;
}
// The mutation context already keeps a stale batch out of the next
// series' state; the snackbars need the same ownership so feedback
// for the old season is not presented on a newly opened page.
const seriesXtreamId = this.shownSeriesId();
const stillCurrent = () =>
this.stalkerStore.currentPlaylist()?._id === playlistId &&
this.shownSeriesId() === seriesXtreamId;
this.seasonWatchBatchRunning.set(true);
try {
await this.runWatchToggleBatch(
request,
playlistId,
stillCurrent,
SEASON_WATCH_FEEDBACK
);
} finally {
this.endWatchBatch();
}
}
async handleSeriesPlaybackToggleRequested(
request: SeasonContainerSeriesPlaybackToggleRequest
): Promise<void> {
const bindings = this.bindings();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!bindings || !playlistId || this.seasonWatchBatchRunning()) {
return;
}
const pendingSeasons = bindings.isVodSeries()
? bindings
.vodSeriesSeasons()
.filter((season) => isVodSeasonHydrationPending(season))
: [];
// An empty request is only meaningful when unloaded seasons remain:
// every loaded episode is watched, so the container could not build
// a target list, but hydration below may still surface unwatched
// episodes to mark.
if (request.requests.length === 0 && pendingSeasons.length === 0) {
return;
}
const seriesXtreamId = this.shownSeriesId();
const stillCurrent = () =>
this.stalkerStore.currentPlaylist()?._id === playlistId &&
this.shownSeriesId() === seriesXtreamId;
this.seasonWatchBatchRunning.set(true);
try {
let effective: SeasonContainerSeriesPlaybackToggleRequest | null =
request;
if (pendingSeasons.length > 0) {
const hydrated = await this.hydrateSeasonsForSeriesToggle(
bindings,
pendingSeasons,
stillCurrent
);
if (hydrated !== 'complete') {
if (hydrated === 'failed' && stillCurrent()) {
this.notifySeasonWatchToggle(
SERIES_WATCH_FEEDBACK.failed
);
}
return;
}
// The reconcile effect only flushes on the next change-
// detection tick; rebuild the maps synchronously so the
// batch below sees the hydrated episodes' scoped and legacy
// rows (see applyReconciledSeriesPositions).
this.positions.applyReconciledSeriesPositions();
effective = buildSeriesWatchToggleRequest({
seasons: bindings.mappedSeasons(),
seriesId: seriesXtreamId,
playlistId,
isEpisodeWatched: (episode) =>
isPortalPlaybackWatched(
this.positions
.episodePlaybackPositions()
.get(Number(episode.id))
),
excludedEpisodeIds: bindings.excludedEpisodeIds(),
// Keep the direction the user clicked; re-inference over
// the now-complete data could flip a "mark" into an
// unwatch when everything turned out watched.
markWatched: request.markWatched,
});
if (!effective) {
if (stillCurrent()) {
this.notifySeasonWatchToggle(
SERIES_WATCH_FEEDBACK.marked,
{ count: 0 }
);
}
return;
}
}
await this.runWatchToggleBatch(
effective,
playlistId,
stillCurrent,
SERIES_WATCH_FEEDBACK
);
} finally {
this.endWatchBatch();
}
}
private currentSeriesKey(): string {
return this.bindings()?.currentSeriesKey() ?? '';
}
private shownSeriesId(): number {
return toStalkerSeriesId(this.bindings()?.displayItem()?.id ?? 0);
}
/**
* The batch settled: the choice held meanwhile goes through the usual
* gates, unless the viewer switched series since. Episode identities
* overlap across series, so it must never resolve against another one.
*/
private endWatchBatch(): void {
this.seasonWatchBatchRunning.set(false);
const held = this.choiceHeldForBatch;
this.choiceHeldForBatch = null;
if (held && held.seriesKey === this.currentSeriesKey()) {
held.play();
}
}
/**
* Sequential on purpose: loadEpisodesForSeason snapshots the season VM
* array before its writes, so concurrent calls clobber each other's
* loading flags; and one request at a time keeps the portal load bounded.
*/
private async hydrateSeasonsForSeriesToggle(
bindings: StalkerSeriesWatchToggleBindings,
pendingSeasons: readonly VodSeriesSeasonVm[],
stillCurrent: () => boolean
): Promise<'complete' | 'failed' | 'superseded'> {
for (const season of pendingSeasons) {
// A tab click may have loaded this season meanwhile.
const current = bindings
.vodSeriesSeasons()
.find((candidate) => candidate.id === season.id);
if (!current || !isVodSeasonHydrationPending(current)) {
continue;
}
const answered = await bindings.loadEpisodesForSeason(current);
if (!stillCurrent()) {
return 'superseded';
}
if (!answered) {
return 'failed';
}
}
return 'complete';
}
private runWatchToggleBatch(
request: SeasonContainerSeriesPlaybackToggleRequest,
playlistId: string,
stillCurrent: () => boolean,
feedback: StalkerWatchToggleFeedback
): Promise<void> {
return runStalkerWatchToggleBatch({
request,
playlistId,
stillCurrent,
feedback,
persist: (id, position) =>
this.positions.persistSeriesPosition(id, position),
clear: (id, contentXtreamId) =>
this.positions.clearSeriesPosition(id, contentXtreamId),
refreshCatalogPositions: (id) =>
this.catalogFacade
?.refreshPositions(id)
.catch((error: unknown) =>
this.logger.warn(
'Catalog position refresh failed',
error
)
),
notify: (key, params) => this.notifySeasonWatchToggle(key, params),
logger: this.logger,
});
}
private notifySeasonWatchToggle(key: string, params?: object): void {
this.snackBar.open(
this.translateService.instant(key, params),
undefined,
{ duration: 5000 }
);
}
}
@@ -0,0 +1,125 @@
import type { WritableSignal } from '@angular/core';
import {
getVodSeriesSeasonKey,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
/** What the loader needs from the page that shows the seasons. */
export interface StalkerVodSeasonEpisodeLoaderHost {
/** Seasons of the series on screen; a load writes its loading flag and episodes here. */
readonly seasons: WritableSignal<VodSeriesSeasonVm[]>;
/** Identity of the series on screen: part of the key in-flight loads are shared by. */
readonly ownerKey: () => string;
readonly fetchEpisodes: (
videoId: string,
seasonId: string
) => Promise<VodSeriesSeasonVm['episodes']>;
readonly logError: (message: string, error: unknown) => void;
}
/** Lazy episode loads of Ministra VOD-series seasons (`is_series=1`). */
export class StalkerVodSeasonEpisodeLoader {
private readonly loads = new Map<
string,
{ season: VodSeriesSeasonVm; promise: Promise<boolean> }
>();
constructor(private readonly host: StalkerVodSeasonEpisodeLoaderHost) {}
/**
* Loads episodes for a specific VOD season.
*
* Single-flight per season: a tab click, the spillover prefetch, the
* quick-start recursion, and the series-toggle hydration can all ask for
* the same season — a second concurrent request would duplicate portal
* traffic, and its failure could abort a series toggle whose original
* request succeeded.
*/
/** Resolves true when the portal answered, false when the request failed. */
load(season: VodSeriesSeasonVm): Promise<boolean> {
const key = JSON.stringify([
this.host.ownerKey(),
season.video_id,
season.id,
getVodSeriesSeasonKey(season),
]);
const inFlight = this.loads.get(key);
if (inFlight && this.host.seasons().includes(inFlight.season)) {
return inFlight.promise;
}
const load = this.fetch(season).finally(() => {
if (this.loads.get(key)?.promise === load) {
this.loads.delete(key);
}
});
const loadingSeason = this.host
.seasons()
.find(
(candidate) =>
candidate.id === season.id &&
candidate.video_id === season.video_id
);
if (loadingSeason) {
this.loads.set(key, {
season: loadingSeason,
promise: load,
});
}
return load;
}
private async fetch(season: VodSeriesSeasonVm): Promise<boolean> {
// Set loading state in local signal
const seasons = this.host.seasons();
const index = seasons.findIndex(
(s) =>
s.id === season.id &&
s.video_id === season.video_id &&
getVodSeriesSeasonKey(s) === getVodSeriesSeasonKey(season)
);
if (index === -1) return false;
const updatedSeasons = [...seasons];
const loadingSeason = { ...updatedSeasons[index], isLoading: true };
updatedSeasons[index] = loadingSeason;
this.host.seasons.set(updatedSeasons);
try {
const episodes = await this.host.fetchEpisodes(
season.video_id,
season.id
);
// Update with loaded episodes
const newSeasons = [...this.host.seasons()];
// Only the exact loading VM owns this response. A navigation or
// refresh can reuse provider ids while replacing the season list.
const newIndex = newSeasons.indexOf(loadingSeason);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
episodes: episodes,
// Even an EMPTY answer marks the season loaded: the
// portal spoke, so it must stop counting as "unloaded"
// (label/verdict gating and series-toggle hydration).
episodesLoaded: true,
isLoading: false,
};
this.host.seasons.set(newSeasons);
}
return newIndex !== -1;
} catch (error) {
this.host.logError('Failed to load episodes', error);
const newSeasons = [...this.host.seasons()];
const newIndex = newSeasons.indexOf(loadingSeason);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
isLoading: false,
};
this.host.seasons.set(newSeasons);
}
return false;
}
}
}