mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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;
|
||||
}
|
||||
+187
@@ -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);
|
||||
}
|
||||
}
|
||||
+331
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
-625
@@ -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);
|
||||
});
|
||||
});
|
||||
+67
-867
File diff suppressed because it is too large.
Load diff
+281
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+273
@@ -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,
|
||||
};
|
||||
}
|
||||
+192
@@ -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);
|
||||
});
|
||||
});
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
+323
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+125
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user