diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-id.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-id.ts new file mode 100644 index 000000000..877482424 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-id.ts @@ -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; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-queue.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-queue.ts new file mode 100644 index 000000000..2a30fc75b --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-queue.ts @@ -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; + /** 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>(); + private readonly pendingLoads = new Map< + StalkerSeriesPositionContext, + Set + >(); + private readonly reloadKeys = new Set(); + 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 { + 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 + ): Promise { + 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 { + return this.mutationQueues.get(mutationKey) ?? Promise.resolve(); + } + + private trackPendingLoad( + context: StalkerSeriesPositionContext, + generation: number + ): void { + const generations = this.pendingLoads.get(context) ?? new Set(); + 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); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-positions.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-positions.service.ts new file mode 100644 index 000000000..ee3004f93 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-positions.service.ts @@ -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; + readonly mappedSeasons: Signal>; +} + +/** + * 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( + null + ); + readonly episodePlaybackPositions = signal< + Map + >(new Map()); + readonly rawSeriesPositions = signal([]); + /** + * `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(null); + private readonly legacyPositionByTrackingId = signal< + Map + >(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 { + 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 { + 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); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index d6937ca07..e27488ffc 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -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; - const selectedContentType = signal<'series' | 'vod'>('series'); - const selectedItem = signal(null); - const serialSeasonsResource = signal([]); - const vodSeriesSeasonsResource = signal([]); - 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 { 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 - ) => { - 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); - }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 3c250b61b..a4fd531f4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -16,7 +16,6 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component'; -import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; import { findStalkerResumeLazySeason, resolveStalkerResumeEpisode, @@ -40,7 +39,6 @@ import { SimilarRailComponent, ViewInPortalActionComponent, VodMoreMenuComponent, - buildSeriesWatchToggleRequest, scrollToCastCrewRow, } from '@iptvnator/ui/components'; import { @@ -53,14 +51,11 @@ import { XtreamSerieEpisode, pickSeasonMarkedTitle, seriesStatusLabelKey, - youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; import { isLiveExternalPlayerSession, replaceOwnedExternalSession, - isPortalPlaybackWatched, PORTAL_EXTERNAL_PLAYBACK, - PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, createLogger, consumeStalkerReturnMarker, @@ -108,12 +103,10 @@ import { getStalkerSeriesQuickStartButton, type StalkerQuickStartButton, } from './stalker-series-quick-start'; -import { - clearStalkerSeriesPosition, - reconcileStalkerSeriesPositions, - saveStalkerSeriesPosition, - StalkerSeriesPositionPartialSaveError, -} from './stalker-series-position-compatibility'; +import { toStalkerSeriesId } from './stalker-series-id'; +import { StalkerSeriesPositionsService } from './stalker-series-positions.service'; +import { StalkerSeriesWatchToggleService } from './stalker-series-watch-toggle.service'; +import { StalkerVodSeasonEpisodeLoader } from './stalker-vod-season-episode-loader'; import { createStalkerSeriesDownloadAdapter, STALKER_SERIES_DOWNLOAD_MODES, @@ -128,40 +121,6 @@ import { type StalkerEpisodePlaybackStructuralIdentity, } from './stalker-episode-playback-session-key'; -interface SeriesPositionContext { - readonly generation: number; - readonly playlistId: string; - readonly seriesXtreamId: number; - readonly mutationKey: string; -} - -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. -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', -}; - -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', -}; - interface StalkerSeriesPlaybackRequestContext { readonly generation: number; readonly usesEmbeddedPlayer: boolean; @@ -201,6 +160,8 @@ interface StalkerSeriesPlaybackRequestContext { StalkerSeriesTmdbSeasonsService, StalkerSeriesHeroPresenter, StalkerSeriesMenuService, + StalkerSeriesPositionsService, + StalkerSeriesWatchToggleService, ], }) export class StalkerSeriesViewComponent implements OnDestroy { @@ -209,27 +170,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly menu = inject(StalkerSeriesMenuService); private readonly seasonContainerRef = viewChild('seasonContainer'); - 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 positions = inject(StalkerSeriesPositionsService); + private readonly watchToggle = inject(StalkerSeriesWatchToggleService); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly router = inject(Router); private readonly location = inject(Location); @@ -238,46 +180,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { PlaybackPositionRuntimeBridgeService ); private readonly snackBar = inject(MatSnackBar); - // Optional: absent in collection-detail mounts outside the catalog. - private readonly catalogFacade = inject(StalkerCatalogFacadeService, { - optional: true, - }); private readonly translateService = inject(TranslateService); readonly backClicked = output(); private readonly logger = createLogger('StalkerSeriesView'); readonly inlinePlayback = signal(null); - readonly episodePlaybackPositions = signal< - Map - >(new Map()); - private 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. - */ - private readonly seriesPositionsLoadedKey = signal(null); + readonly episodePlaybackPositions = this.positions.episodePlaybackPositions; private readonly seriesResumeTarget = inject(STALKER_SERIES_RESUME_TARGET); private consumedSeriesResumeKey: string | null = null; private readonly seriesResumeSeasonHydration = new StalkerResumeSeasonHydration(); - private readonly legacyPositionByTrackingId = signal< - Map - >(new Map()); - private activeSeriesPositionContext: SeriesPositionContext | null = null; - private seriesPositionContextGeneration = 0; - private readonly seriesPositionMutationQueues = new Map< - string, - Promise - >(); - private readonly pendingSeriesPositionLoads = new Map< - SeriesPositionContext, - Set - >(); - private readonly seriesPositionReloadKeys = new Set(); - private seriesPositionsLoadGeneration = 0; private seriesPlaybackRequestGeneration = 0; private currentSeriesPlaybackOwnerKey = ''; private readonly inlinePlaybackEpisodeIdentity = @@ -294,15 +205,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { private readonly pendingStartSeriesIds = signal([]); /** Episode choices made while a forced MPV/VLC launch is mid-flight. */ private readonly launchQueue = new StalkerSeriesLaunchQueue(); - /** - * 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; /** `playlist:series` of the series on screen; provider ids collide across playlists. */ readonly currentSeriesKey = computed( () => @@ -312,7 +214,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly startPending = computed(() => this.pendingStartSeriesIds().includes(this.currentSeriesKey()) ); - readonly seasonWatchBatchRunning = signal(false); + readonly seasonWatchBatchRunning = this.watchToggle.seasonWatchBatchRunning; /** * Optional input for VOD items with embedded series array (vclub mode) @@ -437,6 +339,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { resetProgress: () => this.resetProgress(), openExternal: (player) => this.openQuickStartExternally(player), }); + this.positions.bind({ + displayItem: this.displayItem, + mappedSeasons: this.mappedSeasons, + }); + this.watchToggle.bind({ + displayItem: this.displayItem, + currentSeriesKey: this.currentSeriesKey, + isVodSeries: this.isVodSeries, + vodSeriesSeasons: this.vodSeriesSeasons, + mappedSeasons: this.mappedSeasons, + excludedEpisodeIds: () => this.seriesWatchExcludedIds(), + loadEpisodesForSeason: (season) => + this.loadEpisodesForSeason(season), + }); effect(() => { const ownerKey = this.seriesPlaybackOwnerKey(); untracked(() => this.syncSeriesPlaybackOwner(ownerKey)); @@ -506,33 +422,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { // Effect to load playback positions for Stalker series effect(() => { - const item = this.displayItem(); - const playlist = this.stalkerStore.currentPlaylist(); - const normalizedSeriesId = this.toSeriesId(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.activateSeriesPositionContext( - playlist._id, - normalizedSeriesId - ); - void this.loadSeriesPositions(context); - } else { - this.activeSeriesPositionContext = null; - this.seriesPositionContextGeneration++; - this.seriesPositionsLoadGeneration++; - } + this.positions.loadShownSeries(); }); effect(() => { - this.applyReconciledSeriesPositions(); + this.positions.applyReconciledSeriesPositions(); }); // Dashboard "Continue watching" handoff: once the persisted @@ -557,8 +451,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { !playlistId || seriesXtreamId <= 0 || target.seriesXtreamId !== seriesXtreamId || - this.seriesPositionsLoadedKey() !== - this.seriesPositionsKey(playlistId, seriesXtreamId) + this.positions.seriesPositionsLoadedKey() !== + this.positions.seriesPositionsKey( + playlistId, + seriesXtreamId + ) ) { return; } @@ -577,10 +474,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { // Reconciliation attaches positions by exact or legacy // tracking id only; an episode found by coordinates still // resumes at the offset the dashboard card displayed. - const savedOffset = this.rawSeriesPositions().find( - (position) => - position.contentXtreamId === target.contentXtreamId - )?.positionSeconds; + const savedOffset = this.positions + .rawSeriesPositions() + .find( + (position) => + position.contentXtreamId === target.contentXtreamId + )?.positionSeconds; untracked(() => this.onEpisodeClicked(episode, savedOffset)); return; } @@ -661,14 +560,14 @@ export class StalkerSeriesViewComponent implements OnDestroy { // The facade/runtime already saved this row. Repeat the // idempotent upsert because only this view owns the // scoped-to-legacy cleanup mapping. - void this.persistSeriesPosition(playlistId, data).catch( - (error: unknown) => { + void this.positions + .persistSeriesPosition(playlistId, data) + .catch((error: unknown) => { this.logger.error( 'Failed to persist runtime series position', error ); - } - ); + }); } ) ?? null; } @@ -915,106 +814,17 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } - private readonly vodSeasonEpisodeLoads = new Map< - string, - { season: VodSeriesSeasonVm; promise: Promise } - >(); + private readonly vodSeasonLoader = new StalkerVodSeasonEpisodeLoader({ + seasons: this.vodSeriesSeasons, + ownerKey: () => this.seriesPlaybackOwnerKey(), + fetchEpisodes: (videoId, seasonId) => + this.stalkerStore.fetchVodSeriesEpisodes(videoId, seasonId), + logError: (message, error) => this.logger.error(message, error), + }); - /** - * 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. */ loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise { - const key = JSON.stringify([ - this.seriesPlaybackOwnerKey(), - season.video_id, - season.id, - getVodSeriesSeasonKey(season), - ]); - const inFlight = this.vodSeasonEpisodeLoads.get(key); - if (inFlight && this.vodSeriesSeasons().includes(inFlight.season)) { - return inFlight.promise; - } - const load = this.fetchEpisodesForSeason(season).finally(() => { - if (this.vodSeasonEpisodeLoads.get(key)?.promise === load) { - this.vodSeasonEpisodeLoads.delete(key); - } - }); - const loadingSeason = this.vodSeriesSeasons().find( - (candidate) => - candidate.id === season.id && - candidate.video_id === season.video_id - ); - if (loadingSeason) { - this.vodSeasonEpisodeLoads.set(key, { - season: loadingSeason, - promise: load, - }); - } - return load; - } - - private async fetchEpisodesForSeason( - season: VodSeriesSeasonVm - ): Promise { - // Set loading state in local signal - const seasons = this.vodSeriesSeasons(); - 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.vodSeriesSeasons.set(updatedSeasons); - - try { - const episodes = await this.stalkerStore.fetchVodSeriesEpisodes( - season.video_id, - season.id - ); - - // Update with loaded episodes - const newSeasons = [...this.vodSeriesSeasons()]; - // 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.vodSeriesSeasons.set(newSeasons); - } - return newIndex !== -1; - } catch (error) { - this.logger.error('Failed to load episodes', error); - const newSeasons = [...this.vodSeriesSeasons()]; - const newIndex = newSeasons.indexOf(loadingSeason); - if (newIndex !== -1) { - newSeasons[newIndex] = { - ...newSeasons[newIndex], - isLoading: false, - }; - this.vodSeriesSeasons.set(newSeasons); - } - return false; - } + return this.vodSeasonLoader.load(season); } /** @@ -1045,15 +855,9 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (this.seasonWatchBatchRunning()) { // The batch rewrites the very rows a start resumes from: the // choice waits for it, like the Reset and watched rows do. - this.choiceHeldForBatch = { - seriesKey: this.currentSeriesKey(), - play: () => - this.onEpisodeClicked( - episode, - startTimeOverride, - forcePlayer - ), - }; + this.watchToggle.holdChoice(() => + this.onEpisodeClicked(episode, startTimeOverride, forcePlayer) + ); return; } const seriesKey = this.currentSeriesKey(); @@ -1068,20 +872,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.startEpisode(episode, startTimeOverride, forcePlayer); } - /** - * 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(); - } - } - private startEpisode( episode: XtreamSerieEpisode, startTimeOverride?: number, @@ -1208,11 +998,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { } toSeriesId(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; + return toStalkerSeriesId(id); } closeInlinePlayer(): void { @@ -1239,15 +1025,14 @@ export class StalkerSeriesViewComponent implements OnDestroy { positionSeconds: Math.floor(event.currentTime), durationSeconds: Math.floor(event.duration), }; - void this.persistSeriesPosition( - playback.contentInfo.playlistId, - position - ).catch((error: unknown) => { - this.logger.error( - 'Failed to persist inline series position', - error - ); - }); + void this.positions + .persistSeriesPosition(playback.contentInfo.playlistId, position) + .catch((error: unknown) => { + this.logger.error( + 'Failed to persist inline series position', + error + ); + }); } showCopyNotification(): void { @@ -1517,27 +1302,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.closeInlinePlayer(); } - async handlePlaybackToggleRequested( + handlePlaybackToggleRequested( request: SeasonContainerPlaybackToggleRequest ): Promise { - const playlistId = this.stalkerStore.currentPlaylist()?._id; - if (!playlistId) { - return; - } - - if (request.nextPosition) { - await this.persistSeriesPosition(playlistId, request.nextPosition); - } else { - await this.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) - ); + return this.watchToggle.handlePlaybackToggleRequested(request); } handlePlaybackToggleRequestedFromUi( @@ -1553,36 +1321,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } - async handleSeasonPlaybackToggleRequested( + handleSeasonPlaybackToggleRequested( request: SeasonContainerSeasonPlaybackToggleRequest ): Promise { - 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.toSeriesId(this.displayItem()?.id ?? 0); - const stillCurrent = () => - this.stalkerStore.currentPlaylist()?._id === playlistId && - this.toSeriesId(this.displayItem()?.id ?? 0) === seriesXtreamId; - - this.seasonWatchBatchRunning.set(true); - try { - await this.runWatchToggleBatch( - request, - playlistId, - stillCurrent, - SEASON_WATCH_FEEDBACK - ); - } finally { - this.endWatchBatch(); - } + return this.watchToggle.handleSeasonPlaybackToggleRequested(request); } handleSeasonPlaybackToggleRequestedFromUi( @@ -1624,89 +1366,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { : {} ); - async handleSeriesPlaybackToggleRequested( + handleSeriesPlaybackToggleRequested( request: SeasonContainerSeriesPlaybackToggleRequest ): Promise { - const playlistId = this.stalkerStore.currentPlaylist()?._id; - if (!playlistId || this.seasonWatchBatchRunning()) { - return; - } - const pendingSeasons = this.isVodSeries() - ? this.vodSeriesSeasons().filter((season) => - this.isSeasonHydrationPending(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.toSeriesId(this.displayItem()?.id ?? 0); - const stillCurrent = () => - this.stalkerStore.currentPlaylist()?._id === playlistId && - this.toSeriesId(this.displayItem()?.id ?? 0) === seriesXtreamId; - - this.seasonWatchBatchRunning.set(true); - try { - let effective: SeasonContainerSeriesPlaybackToggleRequest | null = - request; - if (pendingSeasons.length > 0) { - const hydrated = await this.hydrateSeasonsForSeriesToggle( - 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.applyReconciledSeriesPositions(); - effective = buildSeriesWatchToggleRequest({ - seasons: this.mappedSeasons(), - seriesId: seriesXtreamId, - playlistId, - isEpisodeWatched: (episode) => - isPortalPlaybackWatched( - this.episodePlaybackPositions().get( - Number(episode.id) - ) - ), - excludedEpisodeIds: this.seriesWatchExcludedIds(), - // 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(); - } + return this.watchToggle.handleSeriesPlaybackToggleRequested(request); } handleSeriesPlaybackToggleRequestedFromUi( @@ -1722,34 +1385,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } - /** - * 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( - 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 = this.vodSeriesSeasons().find( - (candidate) => candidate.id === season.id - ); - if (!current || !this.isSeasonHydrationPending(current)) { - continue; - } - const answered = await this.loadEpisodesForSeason(current); - if (!stillCurrent()) { - return 'superseded'; - } - if (!answered) { - return 'failed'; - } - } - return 'complete'; - } - /** * Mirrors the exclusions the template binds into the season container * (playingEpisodeId / activeEpisodeId / openingEpisodeId): the episode @@ -1765,427 +1400,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { return new Set(ids); } - private async runWatchToggleBatch( - request: SeasonContainerSeriesPlaybackToggleRequest, - playlistId: string, - stillCurrent: () => boolean, - feedback: StalkerWatchToggleFeedback - ): Promise { - // 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 - ? this.persistSeriesPosition(playlistId, item.nextPosition) - : this.clearSeriesPosition(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) { - this.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 this.catalogFacade - ?.refreshPositions(playlistId) - .catch((error: unknown) => - this.logger.warn('Catalog position refresh failed', error) - ); - } - if (!stillCurrent()) { - return; - } - - if (failed === 0) { - this.notifySeasonWatchToggle( - request.markWatched ? feedback.marked : feedback.unmarked, - { count: succeeded } - ); - } else if (succeeded > 0) { - this.notifySeasonWatchToggle( - request.markWatched - ? feedback.partialMarked - : feedback.partialUnmarked, - { count: succeeded, failed } - ); - } else { - this.notifySeasonWatchToggle(feedback.failed); - } - } - - /** - * Maps the raw series position rows onto the currently mapped episodes - * (scoped rows plus compatible legacy promotions). Runs reactively from - * the 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). - */ - private applyReconciledSeriesPositions(): void { - const item = this.displayItem(); - const playlistId = this.stalkerStore.currentPlaylist()?._id; - const seriesXtreamId = this.toSeriesId(item?.id ?? 0); - const rawSeriesPositions = this.rawSeriesPositions(); - const episodesBySeason = this.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 - ); - } - - private notifySeasonWatchToggle(key: string, params?: object): void { - this.snackBar.open( - this.translateService.instant(key, params), - undefined, - { duration: 5000 } - ); - } - - private async loadSeriesPositions( - context: SeriesPositionContext - ): Promise { - const generation = ++this.seriesPositionsLoadGeneration; - this.trackPendingSeriesPositionLoad(context, generation); - try { - await this.waitForSeriesPositionMutations(context.mutationKey); - - if ( - generation !== this.seriesPositionsLoadGeneration || - !this.isSeriesPositionContextActive(context) - ) { - return; - } - - const positions = - await this.playbackPositions.getSeriesPlaybackPositions( - context.playlistId, - context.seriesXtreamId - ); - - if ( - generation !== this.seriesPositionsLoadGeneration || - !this.isSeriesPositionContextActive(context) - ) { - return; - } - - this.rawSeriesPositions.set(positions); - this.seriesPositionsLoadedKey.set( - this.seriesPositionsKey( - context.playlistId, - context.seriesXtreamId - ) - ); - } finally { - this.untrackPendingSeriesPositionLoad(context, generation); - } - } - - private seriesPositionsKey( - playlistId: string, - seriesXtreamId: number - ): string { - return `${playlistId}:${seriesXtreamId}`; - } - - private activateSeriesPositionContext( - playlistId: string, - seriesXtreamId: number - ): SeriesPositionContext { - const context: SeriesPositionContext = { - generation: ++this.seriesPositionContextGeneration, - playlistId, - seriesXtreamId, - mutationKey: JSON.stringify([playlistId, seriesXtreamId]), - }; - this.activeSeriesPositionContext = context; - return context; - } - - private isSeriesPositionContextActive( - context: SeriesPositionContext - ): boolean { - const activeContext = this.activeSeriesPositionContext; - return ( - activeContext === context && - activeContext.generation === context.generation && - this.stalkerStore.currentPlaylist()?._id === context.playlistId && - this.toSeriesId(this.displayItem()?.id ?? 0) === - context.seriesXtreamId - ); - } - - private waitForSeriesPositionMutations(mutationKey: string): Promise { - return ( - this.seriesPositionMutationQueues.get(mutationKey) ?? - Promise.resolve() - ); - } - - private trackPendingSeriesPositionLoad( - context: SeriesPositionContext, - generation: number - ): void { - const generations = - this.pendingSeriesPositionLoads.get(context) ?? new Set(); - generations.add(generation); - this.pendingSeriesPositionLoads.set(context, generations); - } - - private untrackPendingSeriesPositionLoad( - context: SeriesPositionContext, - generation: number - ): void { - const generations = this.pendingSeriesPositionLoads.get(context); - generations?.delete(generation); - if (generations?.size === 0) { - this.pendingSeriesPositionLoads.delete(context); - } - } - - private hasCurrentPendingSeriesPositionLoad( - context: SeriesPositionContext - ): boolean { - return Boolean( - this.pendingSeriesPositionLoads - .get(context) - ?.has(this.seriesPositionsLoadGeneration) - ); - } - - private enqueueSeriesPositionMutation( - context: SeriesPositionContext, - operation: () => Promise - ): Promise { - if (this.hasCurrentPendingSeriesPositionLoad(context)) { - this.seriesPositionReloadKeys.add(context.mutationKey); - } - this.seriesPositionsLoadGeneration++; - const previous = this.waitForSeriesPositionMutations( - context.mutationKey - ); - const result = previous.then(operation); - const barrier = result.then( - () => undefined, - () => undefined - ); - this.seriesPositionMutationQueues.set(context.mutationKey, barrier); - void barrier.then(() => { - if ( - this.seriesPositionMutationQueues.get(context.mutationKey) === - barrier - ) { - this.seriesPositionMutationQueues.delete(context.mutationKey); - this.reloadSeriesPositionsAfterMutations(context.mutationKey); - } - }); - return result; - } - - private reloadSeriesPositionsAfterMutations(mutationKey: string): void { - if (!this.seriesPositionReloadKeys.delete(mutationKey)) { - return; - } - const context = this.activeSeriesPositionContext; - if ( - !context || - context.mutationKey !== mutationKey || - !this.isSeriesPositionContextActive(context) || - this.hasCurrentPendingSeriesPositionLoad(context) - ) { - return; - } - void this.loadSeriesPositions(context); - } - - private getSeriesPositionMutationContext( - playlistId: string, - seriesXtreamId?: number | null - ): SeriesPositionContext | null { - const context = this.activeSeriesPositionContext; - if ( - !context || - context.playlistId !== playlistId || - (seriesXtreamId != null && - context.seriesXtreamId !== seriesXtreamId) - ) { - return null; - } - return context; - } - - private persistSeriesPosition( - playlistId: string, - position: PlaybackPositionData - ): Promise { - const context = this.getSeriesPositionMutationContext( - playlistId, - position.seriesXtreamId - ); - if (!context) { - return Promise.resolve(); - } - const legacyPosition = this.legacyPositionByTrackingId().get( - position.contentXtreamId - ); - return this.enqueueSeriesPositionMutation(context, async () => { - let clearedLegacy: boolean; - try { - clearedLegacy = await saveStalkerSeriesPosition({ - repository: this.migrationPlaybackPositions, - playlistId, - position, - legacyPosition, - }); - } catch (error) { - if ( - error instanceof StalkerSeriesPositionPartialSaveError && - this.isSeriesPositionContextActive(context) - ) { - this.publishSavedSeriesPosition( - position, - legacyPosition, - false - ); - } - throw error; - } - if (!this.isSeriesPositionContextActive(context)) { - return; - } - this.publishSavedSeriesPosition( - position, - legacyPosition, - clearedLegacy - ); - }); - } - - 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 clearSeriesPosition( - playlistId: string, - contentXtreamId: number - ): Promise { - const context = this.getSeriesPositionMutationContext(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.enqueueSeriesPositionMutation(context, async () => { - const clearedLegacy = await clearStalkerSeriesPosition({ - repository: this.migrationPlaybackPositions, - playlistId, - position, - legacyPosition, - }); - if (!this.isSeriesPositionContextActive(context)) { - return; - } - this.publishClearedSeriesPosition( - contentXtreamId, - legacyPosition, - clearedLegacy - ); - }); - } - - 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 async loadAndPlayVodSeriesSeason( season: VodSeriesSeasonVm, visitedSeasonIds = new Set() @@ -2216,18 +1430,4 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } } - - 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); - } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts new file mode 100644 index 000000000..d7a506ff8 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts @@ -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; + const { + selectedContentType, + selectedItem, + serialSeasonsResource, + vodSeriesSeasonsResource, + fetchVodSeriesEpisodes, + isEmbeddedPlayer, + createFixture, + } = createStalkerSeriesViewHarness(() => jest.fn()); + + async function stabilize(): Promise { + 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', + ]); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts new file mode 100644 index 000000000..d9b043755 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts @@ -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(null); + const serialSeasonsResource = signal([]); + const vodSeriesSeasonsResource = signal([]); + 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 + > { + 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 + ) => { + 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, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts new file mode 100644 index 000000000..3440d321c --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts @@ -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; + const { + selectedItem, + serialSeasonsResource, + isSerialSeasonsLoading, + tmdbGetSeason, + createFixture, + } = createStalkerSeriesViewHarness(() => jest.fn()); + + async function stabilize(): Promise { + 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); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-batch.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-batch.ts new file mode 100644 index 000000000..73c4d510c --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-batch.ts @@ -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; + readonly clear: ( + playlistId: string, + contentXtreamId: number + ) => Promise; + /** Re-reads the catalog grid's progress badges; must not reject. */ + readonly refreshCatalogPositions: ( + playlistId: string + ) => Promise | undefined; + /** Shows the translated feedback for `key`. */ + readonly notify: (key: string, params?: object) => void; + readonly logger: Logger; +} + +export async function runStalkerWatchToggleBatch( + batch: StalkerWatchToggleBatch +): Promise { + 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); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-toggle.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-toggle.service.ts new file mode 100644 index 000000000..98fdb20a8 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-toggle.service.ts @@ -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; + /** `playlist:series` of the series on screen. */ + readonly currentSeriesKey: Signal; + readonly isVodSeries: Signal; + readonly vodSeriesSeasons: Signal; + readonly mappedSeasons: Signal>; + /** Episodes playing or launching, which a series toggle never marks. */ + readonly excludedEpisodeIds: () => ReadonlySet; + /** Resolves true when the portal answered, false when the request failed. */ + readonly loadEpisodesForSeason: ( + season: VodSeriesSeasonVm + ) => Promise; +} + +/** + * 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( + 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 { + 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 { + 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 { + 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 { + 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 } + ); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts new file mode 100644 index 000000000..585f71e05 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts @@ -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; + /** 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; + 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 } + >(); + + 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 { + 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 { + // 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; + } + } +}