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