From 74c8adc12e0a778bfe3b40b609082b35181800c2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 25 Apr 2026 22:16:33 +0200 Subject: [PATCH] fix(stalker): series favorites handling Entire-Checkpoint: c6e522b4276c --- .../src/lib/stalker-vod.utils.spec.ts | 31 ++ ...talker-collection-detail.component.spec.ts | 254 +++++++++++++++++ .../stalker-collection-detail.component.ts | 90 ++++-- ...stalker-favorites-button.component.spec.ts | 138 +++++++++ .../stalker-favorites-button.component.ts | 29 +- .../serial-details.component.spec.ts | 269 ++++++++++++++++++ ...xtream-collection-detail.component.spec.ts | 142 +++++++++ .../src/lib/stalker-portal-item.interface.ts | 2 + 8 files changed, 931 insertions(+), 24 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index f1f5db1fb..08c79da86 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -59,6 +59,37 @@ describe('stalker-vod.utils regressions', () => { expect(state.vodDetailsItem).toBeNull(); }); + it('builds a playable VOD details item for regular movies', () => { + const state = createStalkerDetailViewState( + { + id: '42', + cmd: '/media/file_42.mpg', + info: { + name: 'Regular Movie', + movie_image: 'poster.jpg', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }, + 'playlist-1' + ); + + expect(state.itemDetails?.id).toBe('42'); + expect(state.vodDetailsItem).toEqual( + expect.objectContaining({ + type: 'stalker', + playlistId: 'playlist-1', + cmd: '/media/file_42.mpg', + }) + ); + expect(state.vodDetailsItem?.data.id).toBe('42'); + }); + it('favorite toggle uses completion callback path without delayed state update', () => { const addToFavorites = jest.fn( (_item: Record, onDone?: () => void) => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts new file mode 100644 index 000000000..c1f9f983a --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -0,0 +1,254 @@ +import { Component, input, output, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ContentHeroComponent } from 'components'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { PlaylistsService } from 'services'; +import { Playlist } from 'shared-interfaces'; +import { of } from 'rxjs'; +import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component'; +import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; + +@Component({ + selector: 'app-content-hero', + standalone: true, + template: '', +}) +class StubContentHeroComponent { + readonly isLoading = input(false); +} + +@Component({ + selector: 'app-stalker-inline-detail', + standalone: true, + template: '', +}) +class StubStalkerInlineDetailComponent { + readonly categoryId = input<'vod' | 'series' | null>(null); + readonly seriesItem = input(null); + readonly isSeries = input(false); + readonly vodDetailsItem = input(null); + readonly isFavorite = input(false); + readonly backClicked = output(); + readonly playClicked = output(); + readonly favoriteToggled = output(); +} + +describe('StalkerCollectionDetailComponent', () => { + let fixture: ComponentFixture; + let currentPlaylist: ReturnType>; + let selectedContentType: ReturnType< + typeof signal<'vod' | 'itv' | 'series'> + >; + let selectedCategoryId: ReturnType>; + let selectedItem: ReturnType>; + let stalkerStore: { + currentPlaylist: typeof currentPlaylist; + selectedContentType: typeof selectedContentType; + selectedCategoryId: typeof selectedCategoryId; + selectedItem: typeof selectedItem; + setCurrentPlaylist: jest.Mock; + setSelectedContentType: jest.Mock; + setSelectedCategory: jest.Mock; + setSelectedItem: jest.Mock; + addToFavorites: jest.Mock; + removeFromFavorites: jest.Mock; + createLinkToPlayVod: jest.Mock; + }; + + const playlist = { + _id: 'stalker-1', + title: 'Stalker Portal', + portalUrl: 'http://portal.example/portal.php', + macAddress: '00:1A:79:00:00:01', + favorites: [], + } as unknown as Playlist; + + beforeEach(async () => { + currentPlaylist = signal(undefined); + selectedContentType = signal<'vod' | 'itv' | 'series'>('vod'); + selectedCategoryId = signal(null); + selectedItem = signal(null); + stalkerStore = { + currentPlaylist, + selectedContentType, + selectedCategoryId, + selectedItem, + setCurrentPlaylist: jest.fn(async (value: Playlist | undefined) => { + currentPlaylist.set(value); + }), + setSelectedContentType: jest.fn( + (value: 'vod' | 'itv' | 'series') => { + selectedContentType.set(value); + } + ), + setSelectedCategory: jest.fn((value: string | null) => { + selectedCategoryId.set(value); + }), + setSelectedItem: jest.fn((value: unknown) => { + selectedItem.set(value); + }), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + createLinkToPlayVod: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [StalkerCollectionDetailComponent], + providers: [ + { + provide: StalkerStore, + useValue: stalkerStore, + }, + { + provide: PlaylistsService, + useValue: { + getPlaylistById: jest.fn(() => of(playlist)), + getPortalFavorites: jest.fn(() => of([])), + }, + }, + ], + }) + .overrideComponent(StalkerCollectionDetailComponent, { + remove: { + imports: [ContentHeroComponent, StalkerInlineDetailComponent], + }, + add: { + imports: [ + StubContentHeroComponent, + StubStalkerInlineDetailComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(StalkerCollectionDetailComponent); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('opens legacy VOD is_series favorites through the lazy VOD-series flow', async () => { + fixture.componentRef.setInput( + 'item', + buildCollectionItem({ + contentType: 'series', + categoryId: 'series', + stalkerItem: { + id: '1507', + title: 'Flagged Series', + category_id: 'series', + cmd: '/media/file_1507.mpg', + is_series: '1', + }, + }) + ); + + await settleDetail(fixture); + + expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith( + 'vod' + ); + expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod'); + expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: '1507', + is_series: true, + }) + ); + expect(fixture.componentInstance.inlineDetail()).toEqual( + expect.objectContaining({ + categoryId: 'vod', + isSeries: true, + vodDetailsItem: null, + }) + ); + }); + + it('keeps regular Stalker series favorites in regular series mode', async () => { + fixture.componentRef.setInput( + 'item', + buildCollectionItem({ + contentType: 'series', + categoryId: 'series', + stalkerItem: { + id: '30001', + series_id: '30001', + title: 'Regular Series', + category_id: 'series', + }, + }) + ); + + await settleDetail(fixture); + + expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith( + 'series' + ); + expect(fixture.componentInstance.inlineDetail()).toEqual( + expect.objectContaining({ + categoryId: 'series', + isSeries: false, + vodDetailsItem: null, + }) + ); + }); + + it('keeps embedded VOD series favorites in the embedded VOD-series path', async () => { + fixture.componentRef.setInput( + 'item', + buildCollectionItem({ + contentType: 'series', + categoryId: 'series', + stalkerItem: { + id: '20001', + title: 'Embedded Series', + category_id: 'series', + cmd: '/media/file_20001.mpg', + series: [1, 2], + }, + }) + ); + + await settleDetail(fixture); + + expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith( + 'vod' + ); + expect(fixture.componentInstance.inlineDetail()).toEqual( + expect.objectContaining({ + categoryId: 'vod', + isSeries: false, + vodDetailsItem: null, + }) + ); + expect( + fixture.componentInstance.inlineDetail().seriesItem?.series + ).toEqual([1, 2]); + }); +}); + +function buildCollectionItem( + overrides: Partial +): UnifiedCollectionItem { + return { + uid: 'stalker::stalker-1::item-1', + name: 'Item', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Portal', + stalkerId: 'item-1', + ...overrides, + }; +} + +async function settleDetail( + fixture: ComponentFixture +): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + await Promise.resolve(); + fixture.detectChanges(); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 646778b68..ed152ac48 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -40,6 +40,15 @@ interface StalkerCollectionStateSnapshot { selectedItem: unknown; } +type StalkerDetailCategory = 'vod' | 'series'; + +interface StalkerCollectionDetailMode { + category: StalkerDetailCategory; + selectedContentType: StalkerDetailCategory; + hasEmbeddedSeries: boolean; + needsSeriesFetch: boolean; +} + @Component({ selector: 'app-stalker-collection-detail', imports: [ContentHeroComponent, StalkerInlineDetailComponent], @@ -83,11 +92,14 @@ export class StalkerCollectionDetailComponent { readonly itemDetails = signal(null); readonly vodDetailsItem = signal(null); readonly isSelectedVodFavorite = signal(false); + readonly detailCategoryOverride = signal( + null + ); readonly inlineDetail = computed(() => createStalkerInlineDetailState( this.itemDetails(), this.vodDetailsItem(), - this.item()?.contentType === 'series' ? 'series' : 'vod' + this.detailCategoryOverride() ) ); @@ -183,35 +195,24 @@ export class StalkerCollectionDetailComponent { } const stalkerItem = this.resolveStalkerItem(item); - const hasEmbeddedSeries = Array.isArray( - (stalkerItem as { series?: unknown[] }).series - ) - ? (stalkerItem as { series?: unknown[] }).series!.length > 0 - : false; - const needsSeriesFetch = - item.contentType === 'movie' && - !hasEmbeddedSeries && - isStalkerSeriesFlag( - (stalkerItem as { is_series?: unknown }).is_series - ); + const detailMode = this.resolveDetailMode(item, stalkerItem); const itemDetails = buildStalkerSelectedVodItem( stalkerItem as never, - needsSeriesFetch + detailMode.needsSeriesFetch ); - this.stalkerStore.setSelectedContentType( - item.contentType === 'series' ? 'series' : 'vod' - ); + this.detailCategoryOverride.set(detailMode.category); + this.stalkerStore.setSelectedContentType(detailMode.selectedContentType); this.stalkerStore.setSelectedCategory( - item.categoryId ?? toStalkerCategoryId(item.contentType) + this.resolveSelectedCategory(item, stalkerItem, detailMode) ); this.stalkerStore.setSelectedItem(itemDetails); this.itemDetails.set(itemDetails); if ( - item.contentType === 'movie' && - !hasEmbeddedSeries && - !needsSeriesFetch + detailMode.selectedContentType === 'vod' && + !detailMode.hasEmbeddedSeries && + !detailMode.needsSeriesFetch ) { const detailViewState = createStalkerDetailViewState( itemDetails, @@ -267,6 +268,54 @@ export class StalkerCollectionDetailComponent { ) as StalkerPortalItem; } + private resolveDetailMode( + item: UnifiedCollectionItem, + stalkerItem: StalkerPortalItem + ): StalkerCollectionDetailMode { + const hasEmbeddedSeries = Array.isArray( + (stalkerItem as { series?: unknown[] }).series + ) + ? (stalkerItem as { series?: unknown[] }).series!.length > 0 + : false; + const isVodSeries = isStalkerSeriesFlag( + (stalkerItem as { is_series?: unknown }).is_series + ); + const isRegularSeries = + item.contentType === 'series' && !hasEmbeddedSeries && !isVodSeries; + const selectedContentType: StalkerDetailCategory = isRegularSeries + ? 'series' + : 'vod'; + + return { + category: selectedContentType, + selectedContentType, + hasEmbeddedSeries, + needsSeriesFetch: + selectedContentType === 'vod' && + !hasEmbeddedSeries && + isVodSeries, + }; + } + + private resolveSelectedCategory( + item: UnifiedCollectionItem, + stalkerItem: StalkerPortalItem, + detailMode: StalkerCollectionDetailMode + ): string | number { + const categoryId = + item.categoryId ?? + (stalkerItem as { category_id?: string | number }).category_id; + + if ( + detailMode.selectedContentType === 'vod' && + String(categoryId ?? '').toLowerCase() === 'series' + ) { + return 'vod'; + } + + return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType); + } + private syncSelectedVodFavorite(): void { this.isSelectedVodFavorite.set( isSelectedStalkerVodFavorite( @@ -280,6 +329,7 @@ export class StalkerCollectionDetailComponent { const cleared = clearStalkerDetailViewState(); this.itemDetails.set(cleared.itemDetails); this.vodDetailsItem.set(cleared.vodDetailsItem); + this.detailCategoryOverride.set(null); this.isSelectedVodFavorite.set(false); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.spec.ts new file mode 100644 index 000000000..237ad0907 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.spec.ts @@ -0,0 +1,138 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { PlaylistsService } from 'services'; +import { of } from 'rxjs'; +import { FavoritesButtonComponent } from './stalker-favorites-button.component'; + +describe('FavoritesButtonComponent', () => { + let fixture: ComponentFixture; + let selectedContentType: ReturnType>; + let addToFavorites: jest.Mock; + + beforeEach(async () => { + selectedContentType = signal<'vod' | 'series'>('series'); + addToFavorites = jest.fn((_item: unknown, onDone?: () => void) => { + onDone?.(); + }); + + await TestBed.configureTestingModule({ + imports: [ + FavoritesButtonComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: StalkerStore, + useValue: { + currentPlaylist: signal({ _id: 'stalker-1' }), + selectedContentType, + addToFavorites, + removeFromFavorites: jest.fn(), + }, + }, + { + provide: PlaylistsService, + useValue: { + getPortalFavorites: jest.fn(() => of([])), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FavoritesButtonComponent); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('preserves VOD-series favorite payload shape from the VOD series view', () => { + selectedContentType.set('vod'); + fixture.componentRef.setInput('itemId', '1507'); + fixture.componentRef.setInput('item', { + id: '1507', + cmd: '/media/file_1507.mpg', + category_id: '2001', + is_series: '1', + info: { + name: 'VOD Flagged Series', + movie_image: 'poster.jpg', + }, + }); + fixture.detectChanges(); + + fixture.componentInstance.addToFavorites(); + + expect(addToFavorites).toHaveBeenCalledWith( + expect.objectContaining({ + id: '1507', + category_id: '2001', + is_series: true, + title: 'VOD Flagged Series', + cover: 'poster.jpg', + }), + expect.any(Function) + ); + expect(addToFavorites.mock.calls[0][0]).not.toHaveProperty('series_id'); + }); + + it('preserves embedded VOD series arrays in favorite payloads', () => { + selectedContentType.set('vod'); + fixture.componentRef.setInput('itemId', '20001'); + fixture.componentRef.setInput('item', { + id: '20001', + cmd: '/media/file_20001.mpg', + category_id: '2001', + series: [1, 2], + info: { + name: 'Embedded Series', + movie_image: 'embedded.jpg', + }, + }); + fixture.detectChanges(); + + fixture.componentInstance.addToFavorites(); + + expect(addToFavorites).toHaveBeenCalledWith( + expect.objectContaining({ + id: '20001', + category_id: '2001', + series: [1, 2], + title: 'Embedded Series', + cover: 'embedded.jpg', + }), + expect.any(Function) + ); + expect(addToFavorites.mock.calls[0][0]).not.toHaveProperty('series_id'); + }); + + it('writes regular Stalker series favorites with series_id', () => { + selectedContentType.set('series'); + fixture.componentRef.setInput('itemId', '30001'); + fixture.componentRef.setInput('item', { + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + movie_image: 'series.jpg', + }, + }); + fixture.detectChanges(); + + fixture.componentInstance.addToFavorites(); + + expect(addToFavorites).toHaveBeenCalledWith( + expect.objectContaining({ + series_id: '30001', + category_id: 'series', + title: 'Regular Series', + cover: 'series.jpg', + }), + expect.any(Function) + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts index ab0e0184c..2acc460e9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts @@ -7,6 +7,7 @@ import { PlaylistsService } from 'services'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { matchesFavoriteById, + normalizeStalkerSeriesFlag, StalkerSelectedVodItem, } from '@iptvnator/portal/stalker/data-access'; @@ -52,13 +53,33 @@ export class FavoritesButtonComponent { addToFavorites() { const item = this.item(); + const favoriteBase = { + ...item, + title: item.info?.name ?? item.name ?? item.o_name, + cover: item.info?.movie_image ?? item.cover, + added_at: new Date().toISOString(), + }; + + if (this.stalkerStore.selectedContentType() === 'vod') { + this.stalkerStore.addToFavorites( + { + ...favoriteBase, + id: item.id, + category_id: item.category_id ?? 'vod', + series: item.series, + is_series: normalizeStalkerSeriesFlag(item.is_series), + }, + () => { + this.favoritesChanged$.next(); + } + ); + return; + } + this.stalkerStore.addToFavorites( { - ...item, - title: item.info?.name ?? item.name ?? item.o_name, - cover: item.info?.movie_image ?? item.cover, + ...favoriteBase, series_id: item.id, - added_at: new Date().toISOString(), category_id: 'series', }, () => { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts new file mode 100644 index 000000000..799924041 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -0,0 +1,269 @@ +import { Component, input, output, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Location } from '@angular/common'; +import { MatIcon } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute } from '@angular/router'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; +import { + ContentHeroComponent, + SeasonContainerComponent, +} from 'components'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; +import { of } from 'rxjs'; +import { SerialDetailsComponent } from './serial-details.component'; + +@Component({ + selector: 'app-content-hero', + standalone: true, + template: '', +}) +class StubContentHeroComponent { + readonly title = input(undefined); + readonly description = input(undefined); + readonly posterUrl = input(undefined); + readonly backdropUrl = input(undefined); + readonly isLoading = input(false); + readonly errorMessage = input(null); + readonly backClicked = output(); +} + +@Component({ + selector: 'app-season-container', + standalone: true, + template: '
', +}) +class StubSeasonContainerComponent { + readonly seasons = input(null); + readonly seriesId = input(null); + readonly playlistId = input(''); + readonly seriesTitle = input(undefined); + readonly playbackPositions = input(null); + readonly xtreamDownloadContext = input(null); + readonly openingEpisodeId = input(null); + readonly activeEpisodeId = input(null); + readonly episodeClicked = output(); + readonly playbackToggleRequested = output(); +} + +@Component({ + selector: 'app-portal-inline-player', + standalone: true, + template: '', +}) +class StubPortalInlinePlayerComponent { + readonly playback = input(null); + readonly timeUpdate = output(); + readonly closed = output(); + readonly streamUrlCopied = output(); +} + +@Component({ + selector: 'mat-icon', + standalone: true, + template: '', +}) +class StubMatIconComponent {} + +describe('SerialDetailsComponent', () => { + let fixture: ComponentFixture; + const selectedItem = signal(null); + const selectedContentType = signal<'series'>('series'); + const isFavorite = signal(false); + const isLoadingDetails = signal(false); + const detailsError = signal(null); + const currentPlaylist = signal({ + id: 'xtream-1', + serverUrl: 'http://xtream.example', + username: 'user', + password: 'pass', + }); + const fetchSerialDetailsWithMetadata = jest.fn(); + const checkFavoriteStatus = jest.fn(); + const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); + + beforeEach(async () => { + selectedItem.set({ + series_id: 103, + info: { + name: 'Series One', + plot: 'Series plot', + cover: 'cover.jpg', + backdrop_path: [], + genre: 'Drama', + }, + episodes: { + '1': [ + { + id: '1001', + episode_num: 1, + title: 'Episode 1', + season: 1, + }, + ], + }, + }); + isFavorite.set(false); + isLoadingDetails.set(false); + detailsError.set(null); + fetchSerialDetailsWithMetadata.mockClear(); + checkFavoriteStatus.mockClear(); + getSeriesPlaybackPositions.mockClear(); + + await TestBed.configureTestingModule({ + imports: [SerialDetailsComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + categoryId: '3', + serialId: '103', + }, + }, + }, + }, + { + provide: XtreamStore, + useValue: { + selectedItem, + selectedContentType, + isFavorite, + isLoadingDetails, + detailsError, + currentPlaylist, + fetchSerialDetailsWithMetadata, + checkFavoriteStatus, + setSelectedItem: jest.fn((value: unknown) => + selectedItem.set(value) + ), + toggleFavorite: jest.fn(), + constructEpisodeStreamUrl: jest.fn(), + addRecentItem: jest.fn(), + backfillContentBackdrop: jest.fn(), + }, + }, + { + 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: jest.fn().mockReturnValue(false), + openResolvedPlayback: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + }, + }, + { + provide: Location, + useValue: { + back: jest.fn(), + }, + }, + ], + }) + .overrideComponent(SerialDetailsComponent, { + remove: { + imports: [ + ContentHeroComponent, + MatIcon, + PortalInlinePlayerComponent, + SeasonContainerComponent, + TranslatePipe, + ], + }, + add: { + imports: [ + StubContentHeroComponent, + StubMatIconComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(SerialDetailsComponent); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('initializes series metadata and renders the season container', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fetchSerialDetailsWithMetadata).toHaveBeenCalledWith({ + serialId: '103', + categoryId: 3, + }); + expect(checkFavoriteStatus).toHaveBeenCalledWith( + 103, + 'xtream-1', + 'series' + ); + expect(getSeriesPlaybackPositions).toHaveBeenCalledWith( + 'xtream-1', + 103 + ); + + const seasonContainer = fixture.debugElement.query( + By.directive(StubSeasonContainerComponent) + )?.componentInstance as StubSeasonContainerComponent | undefined; + + expect(seasonContainer).toBeDefined(); + expect(seasonContainer?.seriesId()).toBe(103); + expect(seasonContainer?.playlistId()).toBe('xtream-1'); + expect(seasonContainer?.seasons()).toEqual({ + '1': [ + { + id: '1001', + episode_num: 1, + title: 'Episode 1', + season: 1, + }, + ], + }); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts new file mode 100644 index 000000000..857e67b31 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts @@ -0,0 +1,142 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { + XtreamPlaylistData, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { PlaylistsService } from 'services'; +import { Playlist } from 'shared-interfaces'; +import { of } from 'rxjs'; +import { SerialDetailsComponent } from './serial-details/serial-details.component'; +import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component'; + +describe('XtreamCollectionDetailComponent', () => { + let fixture: ComponentFixture; + let playlistId: ReturnType>; + let currentPlaylist: ReturnType< + typeof signal + >; + let selectedContentType: ReturnType< + typeof signal<'live' | 'vod' | 'series'> + >; + let selectedCategoryId: ReturnType>; + let selectedItem: ReturnType>; + let isLoadingDetails: ReturnType>; + let detailsError: ReturnType>; + + beforeEach(async () => { + playlistId = signal(''); + currentPlaylist = signal(null); + selectedContentType = signal<'live' | 'vod' | 'series'>('vod'); + selectedCategoryId = signal(null); + selectedItem = signal(null); + isLoadingDetails = signal(false); + detailsError = signal(null); + + await TestBed.configureTestingModule({ + imports: [XtreamCollectionDetailComponent], + providers: [ + { + provide: XtreamStore, + useValue: { + playlistId, + currentPlaylist, + selectedContentType, + selectedCategoryId, + selectedItem, + isLoadingDetails, + detailsError, + setPlaylistId: jest.fn((value: string) => + playlistId.set(value) + ), + setCurrentPlaylist: jest.fn( + (value: XtreamPlaylistData | null) => + currentPlaylist.set(value) + ), + setSelectedContentType: jest.fn( + (value: 'live' | 'vod' | 'series') => + selectedContentType.set(value) + ), + setSelectedCategory: jest.fn( + (value: number | null) => + selectedCategoryId.set(value) + ), + setSelectedItem: jest.fn((value: unknown) => + selectedItem.set(value) + ), + setIsLoadingDetails: jest.fn((value: boolean) => + isLoadingDetails.set(value) + ), + setDetailsError: jest.fn((value: string | null) => + detailsError.set(value) + ), + }, + }, + { + provide: PlaylistsService, + useValue: { + getPlaylistById: jest.fn(() => + of({ + _id: 'xtream-1', + title: 'Xtream Portal', + serverUrl: 'http://xtream.example', + username: 'user', + password: 'pass', + } as Playlist) + ), + }, + }, + ], + }) + .overrideComponent(XtreamCollectionDetailComponent, { + set: { + template: '', + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(XtreamCollectionDetailComponent); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('opens Xtream series favorites with the serial detail route context', async () => { + fixture.componentRef.setInput( + 'item', + { + uid: 'xtream::xtream-1::series:103', + name: 'Series One', + contentType: 'series', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream Portal', + xtreamId: 103, + categoryId: 3, + } satisfies UnifiedCollectionItem + ); + + fixture.detectChanges(); + await fixture.whenStable(); + await Promise.resolve(); + fixture.detectChanges(); + + expect(selectedContentType()).toBe('series'); + expect(selectedCategoryId()).toBe(3); + expect(fixture.componentInstance.detailComponent()).toBe( + SerialDetailsComponent + ); + expect( + fixture.componentInstance + .detailInjector() + ?.get(ActivatedRoute) + .snapshot.params + ).toEqual({ + categoryId: '3', + serialId: '103', + }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-portal-item.interface.ts b/libs/shared/interfaces/src/lib/stalker-portal-item.interface.ts index 85b946a35..819ec3c7d 100644 --- a/libs/shared/interfaces/src/lib/stalker-portal-item.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-portal-item.interface.ts @@ -21,6 +21,8 @@ export interface StalkerPortalItem { category_id?: string | number; stream_type?: string; is_series?: boolean | number | string; + /** Embedded VOD-series episode numbers preserved for VOD favorites. */ + series?: unknown[]; added_at?: string | number; cmd?: string; }