From f0be21486d32e4090162f6f610a901ebfebdc597 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:47:36 +0100 Subject: [PATCH] refactor(stalker): add VOD series adapters and tests - Implemented `stalker-series.adapters.ts` for mapping VOD series seasons and episodes. - Added utility functions for handling VOD series items and mapping to view models. - Created unit tests for the new adapter functions in `stalker-series.adapters.spec.ts`. - Updated `stalker.store.ts` to manage VOD series state and handle favorites. - Enhanced `category-content-view` component to support favorite functionality for VOD items. - Refactored favorite management in `playlists.service.ts` to ensure proper ID handling. --- .../favorites-button.component.ts | 41 ++- .../recently-viewed.component.ts | 155 +++++----- .../stalker-favorites.component.html | 14 +- .../stalker-favorites.component.ts | 171 +++++------ .../stalker-live-stream-layout.component.ts | 63 ++-- .../stalker-search.component.html | 1 + .../stalker-search.component.ts | 136 +++++---- .../stalker-series-view.component.html | 14 +- .../stalker-series-view.component.ts | 288 +++++------------- .../stalker/stalker-series.adapters.spec.ts | 90 ++++++ .../app/stalker/stalker-series.adapters.ts | 232 ++++++++++++++ apps/web/src/app/stalker/stalker.store.ts | 251 ++++++++++----- .../category-content-view.component.html | 1 + .../category-content-view.component.ts | 107 ++++--- libs/services/src/lib/playlists.service.ts | 28 +- 15 files changed, 967 insertions(+), 625 deletions(-) create mode 100644 apps/web/src/app/stalker/stalker-series.adapters.spec.ts create mode 100644 apps/web/src/app/stalker/stalker-series.adapters.ts diff --git a/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts b/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts index 9e4213dc0..f5332c020 100644 --- a/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts +++ b/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts @@ -1,10 +1,11 @@ import { AsyncPipe } from '@angular/common'; -import { Component, inject, input, output } from '@angular/core'; +import { Component, inject, input } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; import { BehaviorSubject, map, switchMap } from 'rxjs'; import { PlaylistsService } from 'services'; import { StalkerStore } from '../stalker.store'; +import { matchesFavoriteById } from '../stalker-vod.utils'; @Component({ selector: 'app-favorites-button', @@ -17,9 +18,7 @@ export class FavoritesButtonComponent { private stalkerStore = inject(StalkerStore); readonly itemId = input.required(); - - readonly addToFavoritesClicked = output(); - readonly removeFromFavoritesClicked = output(); + readonly item = input.required(); private readonly favoritesChanged$ = new BehaviorSubject(undefined); @@ -30,25 +29,39 @@ export class FavoritesButtonComponent { ) ), map((favorites) => - favorites.some( - (i) => - (i as any).movie_id === this.itemId() || - (i as any).id === this.itemId() + favorites.some((favorite) => + matchesFavoriteById(favorite, this.itemId()) ) ) ); removeFromFavorites() { - this.removeFromFavoritesClicked.emit(); - setTimeout(() => { + this.stalkerStore.removeFromFavorites(this.itemId(), () => { this.favoritesChanged$.next(); - }, 100); + }); } addToFavorites() { - this.addToFavoritesClicked.emit(); - setTimeout(() => { + const item = this.item(); + this.stalkerStore.addToFavorites( + { + ...item, + title: item.info?.name ?? item.name ?? item.o_name, + cover: item.info?.movie_image ?? item.cover, + series_id: item.id, + added_at: new Date().toISOString(), + category_id: 'series', + }, + () => { + this.favoritesChanged$.next(); + } + ); + } + + forceRefresh() { + // Exposed for callers that need manual refresh after external updates. + if (this.stalkerStore.currentPlaylist()?._id) { this.favoritesChanged$.next(); - }, 100); + } } } diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts index 5e21ebe57..51965e356 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts @@ -1,16 +1,27 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { rxResource } from '@angular/core/rxjs-interop'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from 'services'; import { VodDetailsItem, - StalkerVodDetails, - createStalkerVodItem, } from 'shared-interfaces'; import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component'; import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { StalkerStore } from '../stalker.store'; +import { createLogger } from '../../shared/utils/logger'; +import { + StalkerSelectedVodItem, + StalkerVodSource, +} from '../models'; +import { + createPortalCollectionResource, + normalizeStalkerEntityIdAsNumber, + toggleStalkerVodFavorite, + clearStalkerDetailViewState, + createStalkerDetailViewState, + createRefreshTrigger, + normalizeStalkerVodDetailsItem, +} from '../stalker-vod.utils'; @Component({ selector: 'app-recently-viewed', @@ -36,28 +47,29 @@ import { StalkerStore } from '../stalker.store'; ], }) export class RecentlyViewedComponent { + private static isCategoryType( + value: string + ): value is 'vod' | 'series' | 'itv' { + return value === 'vod' || value === 'series' || value === 'itv'; + } private readonly playlistService = inject(PlaylistsService); - private readonly refreshTimestamp = signal(Date.now()); + private readonly collectionRefresh = createRefreshTrigger(); private readonly stalkerStore = inject(StalkerStore); private readonly translate = inject(TranslateService); + private readonly logger = createLogger('StalkerRecentlyViewed'); - itemDetails: any = null; + itemDetails: (StalkerVodSource & { category_id?: string }) | null = null; vodDetailsItem: VodDetailsItem | null = null; readonly currentPlaylist = this.stalkerStore.currentPlaylist; - readonly allFavorites = rxResource({ - params: () => { - const portalId = this.stalkerStore.currentPlaylist()?._id; - if (!portalId) return undefined; - return { - portalId, - refreshTimestamp: this.refreshTimestamp(), - }; - }, - stream: ({ params }) => - this.playlistService.getPortalRecentlyViewed(params.portalId), - }); + readonly allFavorites = createPortalCollectionResource( + this.playlistService, + () => this.stalkerStore.currentPlaylist()?._id, + () => this.collectionRefresh.refreshVersion(), + (playlistService, portalId) => + playlistService.getPortalRecentlyViewed(portalId) + ); readonly categories = computed(() => [ { @@ -119,18 +131,23 @@ export class RecentlyViewedComponent { this.allFavorites.value()?.filter((item) => item.category_id === 'itv') ); - removeFromRecentlyViewed(item: any) { - this.stalkerStore.removeFromRecentlyViewed(item.id); - setTimeout(() => { - this.refreshTimestamp.set(Date.now()); - }, 100); + removeFromRecentlyViewed(item: Pick) { + const id = normalizeStalkerEntityIdAsNumber(item.id); + if (id === null) return; + this.stalkerStore.removeFromRecentlyViewed(id, () => { + this.collectionRefresh.refresh(); + }); } - setCategoryId(categoryId: any) { + setCategoryId(categoryId: string) { this.selectedCategoryId.set(categoryId); } - openItem(item: any) { + openItem(item: StalkerVodSource & { category_id: string }) { + if (!RecentlyViewedComponent.isCategoryType(item.category_id)) { + return; + } + this.stalkerStore.setSelectedContentType(item.category_id); switch (item.category_id) { case 'itv': @@ -139,24 +156,16 @@ export class RecentlyViewedComponent { case 'vod': // Normalize the item to ensure is_series flag is properly set const normalizedItem = this.normalizeRecentItem(item); - this.itemDetails = normalizedItem; - this.stalkerStore.setSelectedItem(normalizedItem); - - // Check if this VOD item is actually a series (Ministra is_series=1) - const isVodSeries = - normalizedItem.is_series === true || - normalizedItem.series?.length > 0; - - if (isVodSeries) { - // VOD series - will be rendered as series view - this.vodDetailsItem = null; - } else { - // Regular VOD - create VodDetailsItem for the component - this.vodDetailsItem = createStalkerVodItem( - normalizedItem as StalkerVodDetails, - this.currentPlaylist()?._id ?? '' - ); - } + const detailViewState = createStalkerDetailViewState( + normalizedItem, + this.currentPlaylist()?._id ?? '' + ); + this.itemDetails = { + ...(detailViewState.itemDetails ?? normalizedItem), + category_id: 'vod', + }; + this.vodDetailsItem = detailViewState.vodDetailsItem; + this.stalkerStore.setSelectedItem(detailViewState.itemDetails); break; case 'series': this.itemDetails = item; @@ -180,25 +189,18 @@ export class RecentlyViewedComponent { /** Handle favorite toggle from vod-details component */ onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void { - if (event.item.type === 'stalker') { - if (event.isFavorite) { - this.addToFavorites({ - ...event.item.data, - category_id: 'vod', - title: event.item.data.info?.name, - cover: event.item.data.info?.movie_image, - added_at: new Date().toISOString(), - }); - } else { - this.removeFromFavorites(event.item.data.id); - } - } + toggleStalkerVodFavorite(event, { + addToFavorites: (item, onDone) => this.addToFavorites(item, onDone), + removeFromFavorites: (favoriteId, onDone) => + this.removeFromFavorites(favoriteId, onDone), + }); } /** Handle back from vod-details component */ onVodBack(): void { - this.itemDetails = null; - this.vodDetailsItem = null; + const cleared = clearStalkerDetailViewState(); + this.itemDetails = cleared.itemDetails; + this.vodDetailsItem = cleared.vodDetailsItem; } async createLinkToPlayVodItv( @@ -206,44 +208,27 @@ export class RecentlyViewedComponent { title?: string, thumbnail?: string ) { - console.debug('Create link to play VOD/ITV', cmd, title, thumbnail); + this.logger.debug('Create link to play VOD/ITV', { + cmd, + title, + thumbnail, + }); await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); } - addToFavorites(item: any) { - this.stalkerStore.addToFavorites(item); + addToFavorites(item: Record, onDone?: () => void) { + this.stalkerStore.addToFavorites(item, onDone); } - removeFromFavorites(favoriteId: string) { - this.stalkerStore.removeFromFavorites(favoriteId); + removeFromFavorites(favoriteId: string, onDone?: () => void) { + this.stalkerStore.removeFromFavorites(favoriteId, onDone); } /** * Normalize recent item to ensure is_series flag is properly set * and all required fields are present for the series view */ - private normalizeRecentItem(item: any): any { - // Normalize is_series to boolean true (can be 1, "1", or true) - const rawIsSeries = item.is_series; - const normalizedIsSeries = - rawIsSeries === true || - rawIsSeries === 1 || - rawIsSeries === '1'; - - // Ensure info object exists - const info = item.info || { - name: item.o_name || item.name || item.title || 'Unknown', - o_name: item.o_name, - movie_image: item.cover || item.screenshot_uri || item.logo, - description: item.description, - }; - - return { - ...item, - info: info, - is_series: normalizedIsSeries ? true : undefined, - // Preserve video_id for season fetching - video_id: item.video_id, - }; + private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem { + return normalizeStalkerVodDetailsItem(item); } } diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html index 5e26b7692..7475827e4 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html @@ -5,22 +5,18 @@ - } @else if ( - itemDetails.details?.is_series === true || - itemDetails.details?.is_series === 1 || - itemDetails.details?.is_series === '1' - ) { + } @else if (itemDetails.details?.is_series === true) { } @else if (vodDetailsItem) { + } } } @else { diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts index e9d3b8037..03f283742 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts @@ -1,16 +1,27 @@ -import { Component, computed, inject, signal } from '@angular/core'; -import { rxResource } from '@angular/core/rxjs-interop'; +import { Component, computed, effect, inject, signal } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from 'services'; import { VodDetailsItem, - StalkerVodDetails, - createStalkerVodItem, } from 'shared-interfaces'; import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component'; import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { StalkerStore } from '../stalker.store'; +import { createLogger } from '../../shared/utils/logger'; +import { + StalkerFavoriteItem, +} from '../models'; +import { + clearStalkerDetailViewState, + createPortalFavoritesResource, + createRefreshTrigger, + createStalkerDetailViewState, + normalizeStalkerEntityId, + NormalizedStalkerFavoriteItem, + normalizeStalkerFavoriteItem, + isSelectedStalkerVodFavorite, +} from '../stalker-vod.utils'; @Component({ selector: 'app-stalker-favorites', @@ -37,27 +48,22 @@ import { StalkerStore } from '../stalker.store'; }) export class StalkerFavoritesComponent { private readonly playlistService = inject(PlaylistsService); - private readonly refreshTimestamp = signal(Date.now()); + private readonly favoritesRefresh = createRefreshTrigger(); private readonly stalkerStore = inject(StalkerStore); private readonly translate = inject(TranslateService); + private readonly logger = createLogger('StalkerFavorites'); - itemDetails: any = null; + itemDetails: NormalizedStalkerFavoriteItem | null = null; vodDetailsItem: VodDetailsItem | null = null; + readonly isSelectedVodFavorite = signal(false); readonly currentPlaylist = this.stalkerStore.currentPlaylist; - readonly allFavorites = rxResource({ - params: () => { - const portalId = this.stalkerStore.currentPlaylist()?._id; - if (!portalId) return undefined; - return { - portalId, - refreshTimestamp: this.refreshTimestamp(), - }; - }, - stream: ({ params }) => - this.playlistService.getPortalFavorites(params.portalId), - }); + readonly allFavorites = createPortalFavoritesResource( + this.playlistService, + () => this.stalkerStore.currentPlaylist()?._id, + () => this.favoritesRefresh.refreshVersion() + ); readonly categories = computed(() => [ { @@ -119,20 +125,31 @@ export class StalkerFavoritesComponent { this.allFavorites.value()?.filter((item) => item.category_id === 'itv') ); - removeFromFavorites(item: any) { - this.stalkerStore.removeFromFavorites(item.id); - setTimeout(() => { - this.refreshTimestamp.set(Date.now()); - }, 100); + constructor() { + effect(() => { + this.allFavorites.value(); + this.syncSelectedVodFavorite(); + }); } - setCategoryId(categoryId: any) { + removeFromFavorites(item: Pick, onDone?: () => void) { + this.stalkerStore.removeFromFavorites( + normalizeStalkerEntityId(item.id), + () => { + this.favoritesRefresh.refresh(); + this.syncSelectedVodFavorite(); + onDone?.(); + } + ); + } + + setCategoryId(categoryId: string) { this.selectedCategoryId.set(categoryId); } - openItem(item: any) { - console.debug('Open item', item); - const normalizedItem = this.normalizeFavoriteItem(item); + openItem(item: StalkerFavoriteItem) { + this.logger.debug('Open item', item); + const normalizedItem = normalizeStalkerFavoriteItem(item); switch (item.category_id) { case 'itv': @@ -144,25 +161,16 @@ export class StalkerFavoritesComponent { this.stalkerStore.setSelectedItem(normalizedItem.details); this.stalkerStore.setSelectedContentType('vod'); - // Check if this VOD item is actually a series (Ministra is_series=1) - const isVodSeries = - normalizedItem.details?.is_series === true || - normalizedItem.details?.is_series === 1 || - normalizedItem.details?.is_series === '1' || - item.is_series === true || - item.is_series === 1 || - item.is_series === '1'; - - if (isVodSeries || normalizedItem.details?.series?.length > 0) { - // VOD series - will be rendered as series view - this.vodDetailsItem = null; - } else { - // Regular VOD - create VodDetailsItem for the component - this.vodDetailsItem = createStalkerVodItem( - normalizedItem.details as StalkerVodDetails, - this.currentPlaylist()?._id ?? '' - ); - } + const detailViewState = createStalkerDetailViewState( + normalizedItem.details, + this.currentPlaylist()?._id ?? '' + ); + this.itemDetails = { + ...normalizedItem, + details: detailViewState.itemDetails, + }; + this.vodDetailsItem = detailViewState.vodDetailsItem; + this.syncSelectedVodFavorite(); break; case 'series': this.itemDetails = normalizedItem; @@ -189,59 +197,36 @@ export class StalkerFavoritesComponent { onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void { if (!event.isFavorite && event.item.type === 'stalker') { this.removeFromFavorites({ id: event.item.data.id }); + this.isSelectedVodFavorite.set(false); } } /** Handle back from vod-details component */ onVodBack(): void { - this.itemDetails = null; - this.vodDetailsItem = null; + this.resetDetailsView(true); } - private normalizeFavoriteItem(item: any): any { - if (item.details && item.details.info) { - return item; + onSeriesBack(): void { + this.resetDetailsView(true); + } + + private resetDetailsView(refreshList: boolean): void { + const cleared = clearStalkerDetailViewState(); + this.itemDetails = null; + this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); + if (refreshList) { + this.favoritesRefresh.refresh(); } + } - const source = item.details || item; - const info = source.info || { - name: item.o_name || item.name || item.title || 'Unknown', - o_name: item.o_name, - movie_image: item.cover || item.screenshot_uri || item.logo, - description: item.description, - releasedate: item.releasedate || item.year, - genre: item.genre || item.genres_str, - duration: item.duration, - rating_imdb: item.rating_imdb || item.rating, - country: item.country, - actors: item.actors, - director: item.director, - backdrop_path: - item.backdrop_path || - [item.cover, item.screenshot_uri].filter(Boolean), - }; - - // Normalize is_series to boolean true (can be 1, "1", or true) - const rawIsSeries = - item.is_series || source.is_series; - const normalizedIsSeries = - rawIsSeries === true || - rawIsSeries === 1 || - rawIsSeries === '1'; - - return { - ...item, - details: { - ...source, - info: info, - cmd: item.cmd || source.cmd, - id: item.stream_id || item.id || source.id, - series: item.series || source.series, - is_series: normalizedIsSeries ? true : undefined, - // Preserve video_id for season fetching - video_id: item.video_id || source.video_id, - }, - }; + private syncSelectedVodFavorite(): void { + this.isSelectedVodFavorite.set( + isSelectedStalkerVodFavorite( + this.vodDetailsItem, + this.allFavorites.value() ?? [] + ) + ); } async createLinkToPlayVodItv( @@ -249,7 +234,11 @@ export class StalkerFavoritesComponent { title?: string, thumbnail?: string ) { - console.debug('Create link to play VOD/ITV', cmd, title, thumbnail); + this.logger.debug('Create link to play VOD/ITV', { + cmd, + title, + thumbnail, + }); await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); } } diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 1c86f6df9..9129a8497 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -34,6 +34,15 @@ import { CategoryViewComponent } from '../../xtream-tauri/category-view/category import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component'; import { StalkerStore } from '../stalker.store'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { createLogger } from '../../shared/utils/logger'; +import { + StalkerCategoryItem, + StalkerFavoriteItem, + StalkerItvChannel, +} from '../models'; +import { + normalizeStalkerEntityId, +} from '../stalker-vod.utils'; @Component({ selector: 'app-stalker-live-stream-layout', @@ -69,6 +78,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private readonly playerService = inject(PlayerService); private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); + private readonly logger = createLogger('StalkerLiveStream'); /** Categories */ readonly categories = this.stalkerStore.getCategoryResource; @@ -97,15 +107,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private epgChannelId: number | string | null = null; /** Channel list EPG preview */ - readonly currentPrograms = new Map(); - readonly currentProgramsProgress = new Map(); - readonly programTimings = new Map(); - private readonly requestedEpgChannels = new Set(); + readonly currentPrograms = new Map(); + readonly currentProgramsProgress = new Map(); + readonly programTimings = new Map(); + private readonly requestedEpgChannels = new Set(); private readonly cdr = inject(ChangeDetectorRef); private readonly ngZone = inject(NgZone); /** Favorites */ - readonly favorites = new Map(); + readonly favorites = new Map(); /** Scroll */ readonly scrollContainer = viewChild('scrollContainer'); @@ -120,8 +130,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { .getPortalFavorites(this.stalkerStore.currentPlaylist()?._id) .pipe(takeUntilDestroyed()) .subscribe((favs) => { - favs.forEach((fav: any) => { - this.favorites.set(fav.id, true); + favs.forEach((fav: StalkerFavoriteItem) => { + if (fav.id !== undefined) { + this.favorites.set(normalizeStalkerEntityId(fav.id), true); + } }); }); @@ -228,7 +240,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - selectCategory(item: { category_name: string; category_id: string }) { + selectCategory(item: StalkerCategoryItem) { this.stalkerStore.setSelectedCategory(item.category_id || '*'); this.stalkerStore.setPage(0); } @@ -239,7 +251,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.stalkerStore.setSelectedCategory(null); } - async playChannel(item: any) { + async playChannel(item: StalkerItvChannel) { this.stalkerStore.setSelectedItem(item); const isEmbeddedPlayer = @@ -279,7 +291,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { title: item.o_name || item.name, }); } catch (error) { - console.error('[StalkerLiveStream] Playback failed:', error); + this.logger.error('Playback failed', error); const errorMessage = error?.message === 'nothing_to_play' ? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE') @@ -288,10 +300,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - toggleFavorite(item: any) { - if (this.favorites.has(item.id)) { - this.stalkerStore.removeFromFavorites(item.id); - this.favorites.delete(item.id); + toggleFavorite(item: StalkerItvChannel) { + const itemId = normalizeStalkerEntityId(item.id); + if (this.favorites.has(itemId)) { + this.stalkerStore.removeFromFavorites(itemId); + this.favorites.delete(itemId); } else { this.stalkerStore.addToFavorites({ ...item, @@ -300,7 +313,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { cover: item.logo, added_at: new Date().toISOString(), }); - this.favorites.set(item.id, true); + this.favorites.set(itemId, true); } } @@ -349,15 +362,17 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - private async loadEpgPreviewsForChannels(channels: any[]) { + private async loadEpgPreviewsForChannels(channels: StalkerItvChannel[]) { const newChannels = channels.filter( - (ch) => ch.id && !this.requestedEpgChannels.has(ch.id) + (ch) => + ch.id && + !this.requestedEpgChannels.has(normalizeStalkerEntityId(ch.id)) ); if (newChannels.length === 0) return; // Mark all as requested immediately to avoid duplicates for (const ch of newChannels) { - this.requestedEpgChannels.add(ch.id); + this.requestedEpgChannels.add(normalizeStalkerEntityId(ch.id)); } // Process in batches of 3 with a small delay between batches @@ -376,7 +391,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - private async loadSingleEpgPreview(channelId: number) { + private async loadSingleEpgPreview(channelId: number | string) { try { const items = await this.stalkerStore.fetchChannelEpg( channelId, @@ -384,7 +399,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ); if (items.length > 0) { const program = items[0]; - this.currentPrograms.set(channelId, program.title); + const id = normalizeStalkerEntityId(channelId); + this.currentPrograms.set(id, program.title); const now = Date.now() / 1000; const start = parseInt(program.start_timestamp, 10); @@ -393,11 +409,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { if (start && end && now >= start && now <= end) { const progress = ((now - start) / (end - start)) * 100; - this.currentProgramsProgress.set( - channelId, - progress - ); - this.programTimings.set(channelId, { + this.currentProgramsProgress.set(id, progress); + this.programTimings.set(id, { start: start * 1000, end: end * 1000, }); diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.html b/apps/web/src/app/stalker/stalker-search/stalker-search.component.html index b34713c25..59a88f193 100644 --- a/apps/web/src/app/stalker/stalker-search/stalker-search.component.html +++ b/apps/web/src/app/stalker/stalker-search/stalker-search.component.html @@ -16,6 +16,7 @@ this.currentPlaylist()?._id, + () => this.favoritesRefresh.refreshVersion() + ); + readonly searchResultsResource = resource({ params: () => ({ contentType: this.selectedFilterType(), @@ -101,7 +123,7 @@ export class StalkerSearchComponent { token = result.token ?? undefined; serialNumber = (playlist as Playlist).stalkerSerialNumber; } catch (error) { - console.error('Failed to get stalker token:', error); + this.logger.error('Failed to get stalker token', error); } } @@ -123,7 +145,7 @@ export class StalkerSearchComponent { ); if (response) { const items = response.js?.data || []; - return items.map((item: any) => + return items.map((item: StalkerVodSource) => this.processItemUrls(item, portalUrl) ); } else { @@ -134,6 +156,16 @@ export class StalkerSearchComponent { }, }); + readonly isSelectedVodFavorite = signal(false); + + constructor() { + effect(() => { + // Re-evaluate favorite state whenever favorites resource changes. + this.portalFavorites.value(); + this.syncSelectedVodFavorite(); + }); + } + /** Check if showing item details */ get showingDetails(): boolean { return this.itemDetails !== null; @@ -166,33 +198,14 @@ export class StalkerSearchComponent { } } - selectItem(item: any) { + selectItem(item: StalkerVodSource) { const hasEmbeddedSeries = item.series?.length > 0; const needsSeriesFetch = this.selectedFilterType() === 'vod' && !hasEmbeddedSeries && - (item.is_series === '1' || item.is_series === 1); + isStalkerSeriesFlag(item.is_series); - this.itemDetails = { - id: item.id, - cmd: item.cmd, - series: item.series, - has_files: item.has_files, - is_series: needsSeriesFetch ? true : undefined, - video_id: item.video_id, - info: { - movie_image: item.screenshot_uri, - description: item.description, - name: item.o_name || item.name, - o_name: item.o_name, - director: item.director, - releasedate: item.year, - genre: item.genres_str, - actors: item.actors, - rating_imdb: item.rating_imdb, - rating_kinopoisk: item.rating_kinopoisk, - }, - }; + this.itemDetails = buildStalkerSelectedVodItem(item, needsSeriesFetch); this.stalkerStore.setSelectedItem(this.itemDetails); @@ -200,13 +213,17 @@ export class StalkerSearchComponent { case 'vod': this.stalkerStore.setSelectedContentType('vod'); if (!hasEmbeddedSeries && !needsSeriesFetch) { - const playlist = this.currentPlaylist(); - this.vodDetailsItem = createStalkerVodItem( - this.itemDetails as StalkerVodDetails, - playlist?._id ?? '' + const detailViewState = createStalkerDetailViewState( + this.itemDetails, + this.currentPlaylist()?._id ?? '' ); + this.itemDetails = detailViewState.itemDetails; + this.vodDetailsItem = detailViewState.vodDetailsItem; + this.syncSelectedVodFavorite(); } else { - this.vodDetailsItem = null; + const cleared = clearStalkerDetailViewState(); + this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); } break; case 'series': @@ -231,35 +248,46 @@ export class StalkerSearchComponent { item: VodDetailsItem; isFavorite: boolean; }): void { - if (event.item.type === 'stalker') { - if (event.isFavorite) { - this.addToFavorites({ - ...event.item.data, - category_id: 'vod', - title: event.item.data.info?.name, - cover: event.item.data.info?.movie_image, - added_at: new Date().toISOString(), - }); - } else { - this.removeFromFavorites(event.item.data.id); - } - } + toggleStalkerVodFavorite(event, { + addToFavorites: (item, onDone) => this.addToFavorites(item, onDone), + removeFromFavorites: (favoriteId, onDone) => + this.removeFromFavorites(favoriteId, onDone), + onComplete: () => { + this.favoritesRefresh.refresh(); + this.syncSelectedVodFavorite(); + }, + }); } onVodBack(): void { - this.itemDetails = null; - this.vodDetailsItem = null; + const cleared = clearStalkerDetailViewState(); + this.itemDetails = cleared.itemDetails; + this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); } - addToFavorites(item: any) { - this.stalkerStore.addToFavorites(item); + removeFromFavorites(favoriteId: string, onDone?: () => void) { + this.stalkerStore.removeFromFavorites(favoriteId, onDone); } - removeFromFavorites(favoriteId: string) { - this.stalkerStore.removeFromFavorites(favoriteId); + addToFavorites(item: Record, onDone?: () => void) { + this.stalkerStore.addToFavorites(item, onDone); } - private processItemUrls(item: any, portalUrl: string): any { + private syncSelectedVodFavorite(): void { + const item = this.vodDetailsItem; + this.isSelectedVodFavorite.set( + isSelectedStalkerVodFavorite( + item, + this.portalFavorites.value() ?? [] + ) + ); + } + + private processItemUrls( + item: StalkerVodSource, + portalUrl: string + ): StalkerVodSource { const processed = { ...item }; if (processed.screenshot_uri) { diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html index d490f8b55..1741709cf 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html @@ -7,14 +7,14 @@ > - @if (serial.info.releasedate || serial.info.year) { + @if (serial.info.releasedate) { {{ - serial.info.releasedate || serial.info.year + serial.info.releasedate }} } - @if (serial.info.genre || serial.info.genres_str) { + @if (serial.info.genre) { {{ - serial.info.genre || serial.info.genres_str + serial.info.genre }} } @if (serial.info.rating_imdb) { @@ -53,8 +53,7 @@ @@ -62,7 +61,7 @@ @if (mappedSeasons()) { { - const item = this.displayItem(); - return ( - item?.is_series === true || - item?.is_series === 1 || - item?.is_series === '1' - ); + return isVodSeriesItem(this.displayItem()); }); /** @@ -101,30 +88,10 @@ export class StalkerSeriesViewComponent { constructor() { // Effect to load VOD series seasons when a VOD series item is selected effect(() => { - const item = this.displayItem(); - const isSeries = - item?.is_series === true || - item?.is_series === 1 || - item?.is_series === '1'; - if (isSeries) { + if (this.isVodSeries()) { // Get seasons from the resource const seasons = this.stalkerStore.getVodSeriesSeasonsResource(); - if (seasons && seasons.length > 0) { - // Map seasons to our VodSeriesSeason structure - const mappedSeasons: VodSeriesSeason[] = seasons.map( - (season: any) => ({ - id: season.id, - video_id: season.video_id, - name: - season.name || `Season ${season.season_number}`, - season_number: season.season_number, - episodes: [], - isLoading: false, - isExpanded: false, - }) - ); - this.vodSeriesSeasons.set(mappedSeasons); - } + this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons)); } else { this.vodSeriesSeasons.set([]); } @@ -136,8 +103,13 @@ export class StalkerSeriesViewComponent { const playlist = this.stalkerStore.currentPlaylist(); if (item && playlist?._id) { const seriesId = Number(item.id); - console.log( - `[StalkerSeriesView] Loading positions for series: id=${item.id}, seriesId=${seriesId}, is_series=${item.is_series}` + this.logger.debug( + 'Loading positions for series', + { + id: item.id, + seriesId, + isSeries: item.is_series, + } ); if (!isNaN(seriesId)) { // Load playback positions from the XtreamStore (works for any playlist type) @@ -151,133 +123,38 @@ export class StalkerSeriesViewComponent { * For VOD with embedded series, we create a single "season" with the episodes * For regular series, we use the API-fetched seasons */ - readonly regularSeasons = computed(() => { - const vodItem = this.vodWithSeries(); - if (vodItem?.series?.length > 0) { - // VOD with embedded series - create a pseudo-season structure - return [ - { - id: vodItem.id, - name: vodItem.info?.name || 'Episodes', - cmd: vodItem.cmd, - series: vodItem.series, - }, - ]; - } - - // Regular series - use API-fetched seasons - return this.stalkerStore.getSerialSeasonsResource() as unknown as StalkerSeriesSeason[]; - }); + readonly regularSeasons = computed(() => + mapRegularSeriesSeasons( + this.vodWithSeries(), + this.stalkerStore.getSerialSeasonsResource() + ) + ); /** * Get the item to display details for (either vodWithSeries or selectedItem from store) */ - readonly displayItem = computed(() => { - return this.vodWithSeries() || this.selectedItem(); + readonly displayItem = computed(() => { + const item = this.vodWithSeries() || this.selectedItem(); + return item ? normalizeStalkerVodDetailsItem(item) : null; }); - /** - * Simple hash function to generate consistent numeric IDs from strings - */ - private hashString(str: string): number { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32-bit integer - } - return Math.abs(hash); - } - - /** - * Generate a unique episode ID for Stalker episodes. - * For VOD series: ALWAYS hash with season + episode number because - * the API returns the same ID (series/movie ID) for all episodes. - * For regular series: hash the cmd + episode number - */ - private generateEpisodeId( - episodeId: any, - episodeNum: number, - seasonKey: string, - isVodSeries: boolean - ): number { - if (isVodSeries) { - // For VOD series, ALWAYS create unique ID from season + episode number - // The API-provided ep.id is the same for all episodes (it's the series ID) - return this.hashString(`vod_${seasonKey}_${episodeNum}`); - } else { - // For regular series, create unique ID from cmd + episode number - const cmd = String(episodeId || ''); - return this.hashString(`${cmd}_ep_${episodeNum}`); - } - } - /** * Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent. * Record where string is season number/name. */ readonly mappedSeasons = computed>( () => { - const mapped: Record = {}; - if (this.isVodSeries()) { - // Map VOD Series (async loaded) - this.vodSeriesSeasons().forEach((season) => { - const seasonKey = - season.season_number || season.name || season.id; - // Map existing episodes or empty array - mapped[seasonKey] = (season.episodes || []).map( - (ep) => { - const episodeNum = ep.series_number || ep.episode_num || 0; - return { - episode_num: episodeNum, - title: - ep.name || - `Episode ${episodeNum}`, - container_extension: 'mpg', - info: { - movie_image: - ep.cover || - this.displayItem().info.movie_image, - plot: ep.description || '', - duration: ep.duration - ? `${ep.duration} min` - : '', - }, - // Use unique ID for watched status tracking - id: this.generateEpisodeId(ep.id, episodeNum, seasonKey, true), - // Store original API id for playback - originalId: ep.id, - custom_sid: 'vod-series', // marker - } as unknown as XtreamSerieEpisode; - } - ); - }); - } else { - // Map Regular Series (just numbers) - (this.regularSeasons() || []).forEach((season, index) => { - const seasonKey = (index + 1).toString(); - mapped[seasonKey] = (season.series || []).map( - (epNum) => - ({ - episode_num: epNum, - title: `Episode ${epNum}`, - container_extension: '', - info: { - movie_image: - this.displayItem().info.movie_image, - }, - custom_sid: 'regular-series', // marker - // Use unique ID for watched status tracking - id: this.generateEpisodeId(season.cmd, epNum, seasonKey, false), - // Store cmd for playback - originalCmd: season.cmd, - }) as unknown as XtreamSerieEpisode - ); - }); + return mapVodSeriesEpisodes( + this.vodSeriesSeasons(), + this.displayItem()?.info?.movie_image + ); } - return mapped; + return mapRegularSeriesEpisodes( + this.regularSeasons(), + this.displayItem()?.info?.movie_image + ); } ); @@ -290,10 +167,10 @@ export class StalkerSeriesViewComponent { const seasons = this.vodSeriesSeasons(); const season = seasons.find( - (s) => (s.season_number || s.name || s.id) == seasonKey + (s) => getVodSeriesSeasonKey(s) === seasonKey ); - if (season && (!season.episodes || season.episodes.length === 0)) { + if (season && season.episodes.length === 0) { this.loadEpisodesForSeason(season); } } @@ -301,7 +178,7 @@ export class StalkerSeriesViewComponent { /** * Loads episodes for a specific VOD season */ - async loadEpisodesForSeason(season: VodSeriesSeason) { + async loadEpisodesForSeason(season: VodSeriesSeasonVm) { // Set loading state in local signal const seasons = this.vodSeriesSeasons(); const index = seasons.findIndex((s) => s.id === season.id); @@ -329,10 +206,7 @@ export class StalkerSeriesViewComponent { this.vodSeriesSeasons.set(newSeasons); } } catch (error) { - console.error( - '[StalkerSeriesViewComponent] Failed to load episodes:', - error - ); + this.logger.error('Failed to load episodes', error); const newSeasons = [...this.vodSeriesSeasons()]; const newIndex = newSeasons.findIndex((s) => s.id === season.id); if (newIndex !== -1) { @@ -351,20 +225,22 @@ export class StalkerSeriesViewComponent { isCurrentSeasonLoading(seasonKey: string): boolean { if (!this.isVodSeries()) return false; const season = this.vodSeriesSeasons().find( - (s) => (s.season_number || s.name || s.id) == seasonKey + (s) => getVodSeriesSeasonKey(s) === seasonKey ); - return season?.isLoading || false; + return season?.isLoading ?? false; } /** * Handles episode click from the container */ onEpisodeClicked(episode: XtreamSerieEpisode) { - if ((episode as any).custom_sid === 'vod-series') { + const mappedEpisode = episode as StalkerMappedEpisode; + + if (mappedEpisode.custom_sid === 'vod-series') { // It's a VOD series episode (is_series=1 mode) // Use originalId for playback URL, but use generated id for tracking this.playVodSeriesEpisode({ - originalId: (episode as any).originalId, // For constructing playback URL + originalId: mappedEpisode.originalId, // For constructing playback URL trackingId: episode.id, // The generated unique ID for position tracking name: episode.title, series_number: episode.episode_num, @@ -375,7 +251,7 @@ export class StalkerSeriesViewComponent { const trackingId = Number(episode.id); this.playEpisodeClicked( episode.episode_num, - (episode as any).originalCmd, + mappedEpisode.originalCmd, trackingId ); } @@ -384,11 +260,15 @@ export class StalkerSeriesViewComponent { /** * Play episode - handles regular series and vclub mode */ - playEpisodeClicked(episodeNum: any, cmd?: string, trackingId?: number) { + playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) { const item = this.displayItem(); - console.log( - `[StalkerSeriesView] playEpisodeClicked: episodeNum=${episodeNum}, cmd=${cmd}, trackingId=${trackingId}, seriesId=${item.id}` - ); + if (!item) return; + this.logger.debug('playEpisodeClicked', { + episodeNum, + cmd, + trackingId, + seriesId: item.id, + }); // Get playback position from XtreamStore if available const position = trackingId @@ -409,17 +289,26 @@ export class StalkerSeriesViewComponent { /** * Play VOD series episode */ - playVodSeriesEpisode(episode: any) { + playVodSeriesEpisode(episode: { + originalId?: string; + trackingId: string; + name: string; + series_number: number; + }) { const item = this.displayItem(); + if (!item) return; // Use originalId for playback URL (this is what the Stalker API expects) - const cmd = `/media/file_${episode.originalId}.mpg`; + const cmd = `/media/file_${episode.originalId ?? ''}.mpg`; const episodeName = episode.name || `Episode ${episode.series_number}`; // Use trackingId (generated unique ID) for playback position tracking const trackingId = Number(episode.trackingId); - console.log( - `[StalkerSeriesView] playVodSeriesEpisode: originalId=${episode.originalId}, trackingId=${trackingId}, seriesId=${item.id}, episodeName=${episodeName}` - ); + this.logger.debug('playVodSeriesEpisode', { + originalId: episode.originalId, + trackingId, + seriesId: item.id, + episodeName, + }); // Get playback position from XtreamStore if available const position = this.xtreamStore @@ -437,24 +326,13 @@ export class StalkerSeriesViewComponent { ); } - addToFavorites(item: any) { - const displayItem = this.displayItem(); - this.stalkerStore.addToFavorites({ - ...item, - title: displayItem.info.name, - cover: displayItem.info.movie_image, - series_id: displayItem.id, - added_at: new Date().toISOString(), - category_id: 'series', - }); - } - - removeFromFavorites(serialId: string) { - this.stalkerStore.removeFromFavorites(serialId); - } - goBack() { this.backClicked.emit(); this.stalkerStore.clearSelectedItem(); } -} \ No newline at end of file + + toSeriesId(id: string): number { + const parsed = Number(id); + return Number.isFinite(parsed) ? parsed : 0; + } +} diff --git a/apps/web/src/app/stalker/stalker-series.adapters.spec.ts b/apps/web/src/app/stalker/stalker-series.adapters.spec.ts new file mode 100644 index 000000000..6d552b097 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-series.adapters.spec.ts @@ -0,0 +1,90 @@ +import { + isVodSeriesItem, + mapRegularSeriesEpisodes, + mapRegularSeriesSeasons, + mapVodSeriesEpisodes, + mapVodSeriesSeasonsToVm, +} from './stalker-series.adapters'; +import { StalkerSelectedVodItem } from './models'; + +describe('stalker-series.adapters', () => { + it('detects vod-series flags from heterogeneous payloads', () => { + expect(isVodSeriesItem({ is_series: true })).toBe(true); + expect(isVodSeriesItem({ is_series: 1 })).toBe(true); + expect(isVodSeriesItem({ is_series: '1' })).toBe(true); + expect(isVodSeriesItem({ is_series: false })).toBe(false); + }); + + it('maps VOD-series seasons to VM shape with defaults', () => { + const seasons = mapVodSeriesSeasonsToVm([ + { + id: '10', + video_id: '20', + season_number: '2', + }, + ]); + + expect(seasons).toEqual([ + { + id: '10', + video_id: '20', + season_number: '2', + name: 'Season 2', + episodes: [], + isLoading: false, + isExpanded: false, + }, + ]); + }); + + it('maps VOD-series episodes with stable tracking IDs and originalId metadata', () => { + const mapped = mapVodSeriesEpisodes( + [ + { + id: 's1', + video_id: 'v1', + name: 'Season 1', + season_number: '1', + episodes: [ + { + id: 'api-shared-id', + series_number: 1, + name: 'Episode 1', + }, + { + id: 'api-shared-id', + series_number: 2, + name: 'Episode 2', + }, + ], + isLoading: false, + isExpanded: false, + }, + ], + 'poster.jpg' + ); + + expect(mapped['1']).toHaveLength(2); + expect(mapped['1'][0].custom_sid).toBe('vod-series'); + expect((mapped['1'][0] as any).originalId).toBe('api-shared-id'); + expect(mapped['1'][0].id).not.toBe(mapped['1'][1].id); + }); + + it('maps embedded series payload into regular season episodes', () => { + const regularSeasons = mapRegularSeriesSeasons( + { + id: '100', + cmd: '/media/file_100.mpg', + series: ['1', 2, 'x'], + info: { name: 'Embedded Series' }, + } as StalkerSelectedVodItem, + [] + ); + + const mapped = mapRegularSeriesEpisodes(regularSeasons, 'poster.jpg'); + expect(regularSeasons[0].series).toEqual([1, 2]); + expect(mapped['1']).toHaveLength(2); + expect(mapped['1'][0].custom_sid).toBe('regular-series'); + expect((mapped['1'][0] as any).originalCmd).toBe('/media/file_100.mpg'); + }); +}); diff --git a/apps/web/src/app/stalker/stalker-series.adapters.ts b/apps/web/src/app/stalker/stalker-series.adapters.ts new file mode 100644 index 000000000..d18fcf12a --- /dev/null +++ b/apps/web/src/app/stalker/stalker-series.adapters.ts @@ -0,0 +1,232 @@ +import { XtreamSerieEpisode } from 'shared-interfaces'; +import { + StalkerSeason, + StalkerVodSeriesEpisode, + StalkerVodSeriesSeason, + StalkerVodSource, +} from './models'; +import { isStalkerSeriesFlag } from './stalker-vod.utils'; + +export interface VodSeriesSeasonVm { + id: string; + video_id: string; + name: string; + season_number: string; + episodes: StalkerVodSeriesEpisode[]; + isLoading: boolean; + isExpanded: boolean; +} + +export interface StalkerSeriesSeasonVm { + id: string; + name: string; + cmd?: string; + series: number[]; +} + +export interface StalkerMappedEpisode extends XtreamSerieEpisode { + originalId?: string; + originalCmd?: string; +} + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +} + +function generateEpisodeId( + seed: string, + episodeNum: number, + seasonKey: string, + isVodSeries: boolean +): number { + if (isVodSeries) { + return hashString(`vod_${seasonKey}_${episodeNum}`); + } + return hashString(`${seed}_ep_${episodeNum}`); +} + +function toEpisodeNumber(value: unknown): number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function toNonEmptyString(value: unknown, fallback: string): string { + if (typeof value === 'string' && value.trim() !== '') { + return value; + } + return fallback; +} + +function toSeriesNumbers(values: unknown[] | undefined): number[] { + if (!Array.isArray(values)) { + return []; + } + return values + .map((value) => toEpisodeNumber(value)) + .filter((value) => value > 0); +} + +export function isVodSeriesItem( + item: Pick | null | undefined +): boolean { + return isStalkerSeriesFlag(item?.is_series); +} + +export function mapVodSeriesSeasonsToVm( + seasons: StalkerVodSeriesSeason[] | undefined +): VodSeriesSeasonVm[] { + return (seasons ?? []).map((season) => ({ + id: String(season.id), + video_id: String(season.video_id), + name: toNonEmptyString( + season.name, + `Season ${toNonEmptyString(season.season_number, '0')}` + ), + season_number: toNonEmptyString(season.season_number, ''), + episodes: [], + isLoading: false, + isExpanded: false, + })); +} + +export function mapRegularSeriesSeasons( + vodWithSeries: StalkerVodSource | null, + serialSeasons: StalkerSeason[] | undefined +): StalkerSeriesSeasonVm[] { + if (vodWithSeries?.series && vodWithSeries.series.length > 0) { + return [ + { + id: String(vodWithSeries.id), + name: vodWithSeries.info?.name || 'Episodes', + cmd: vodWithSeries.cmd, + series: toSeriesNumbers(vodWithSeries.series), + }, + ]; + } + + return (serialSeasons ?? []).map((season) => ({ + id: String(season.id), + name: season.name, + cmd: season.cmd, + series: toSeriesNumbers(season.series), + })); +} + +function createBaseEpisode( + id: number, + episodeNum: number, + title: string, + containerExtension: string, + customSid: string, + season: number, + info: XtreamSerieEpisode['info'] +): XtreamSerieEpisode { + return { + id: String(id), + episode_num: episodeNum, + title, + container_extension: containerExtension, + info, + custom_sid: customSid, + added: '', + season, + direct_source: '', + }; +} + +export function mapVodSeriesEpisodes( + seasons: ReadonlyArray, + fallbackPoster?: string +): Record { + const mapped: Record = {}; + + seasons.forEach((season) => { + const seasonKey = season.season_number || season.name || season.id; + const seasonNum = toEpisodeNumber(seasonKey) || 1; + + mapped[seasonKey] = (season.episodes ?? []).map((episode) => { + const episodeNum = + toEpisodeNumber(episode.series_number) || + toEpisodeNumber(episode.episode_num); + const trackingId = generateEpisodeId( + String(episode.id ?? ''), + episodeNum, + seasonKey, + true + ); + + return { + ...createBaseEpisode( + trackingId, + episodeNum, + episode.name || `Episode ${episodeNum}`, + 'mpg', + 'vod-series', + seasonNum, + { + movie_image: episode.cover || fallbackPoster, + plot: episode.description || '', + duration: episode.duration + ? `${episode.duration} min` + : '', + } + ), + originalId: String(episode.id ?? ''), + } as StalkerMappedEpisode; + }); + }); + + return mapped; +} + +export function mapRegularSeriesEpisodes( + seasons: ReadonlyArray, + fallbackPoster?: string +): Record { + const mapped: Record = {}; + + seasons.forEach((season, index) => { + const seasonKey = String(index + 1); + mapped[seasonKey] = (season.series ?? []).map((episodeNum) => { + const trackingId = generateEpisodeId( + String(season.cmd ?? ''), + episodeNum, + seasonKey, + false + ); + + return { + ...createBaseEpisode( + trackingId, + episodeNum, + `Episode ${episodeNum}`, + '', + 'regular-series', + Number(seasonKey), + { + movie_image: fallbackPoster, + } + ), + originalCmd: season.cmd, + } as StalkerMappedEpisode; + }); + }); + + return mapped; +} + +export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string { + return season.season_number || season.name || season.id; +} diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index 897488678..952595e01 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -19,35 +19,43 @@ import { } from 'shared-interfaces'; import { PlayerService } from '../services/player.service'; import { ContentType } from '../xtream/content-type.enum'; -import { StalkerSeason } from './models/stalker-season.interface'; +import { + StalkerCategoryItem, + StalkerContentItem, + StalkerItvChannel, + StalkerSeason, + StalkerVodSeriesEpisode, + StalkerVodSeriesSeason, + StalkerVodSource, +} from './models'; import { StalkerContentTypes } from './stalker-content-types'; - -interface StalkerCategoryItem { - category_id: string; - category_name: string; -} +import { createLogger } from '../shared/utils/logger'; +import { + normalizeStalkerEntityId, + normalizeStalkerEntityIdAsNumber, +} from './stalker-vod.utils'; interface StalkerState { selectedContentType: 'vod' | 'itv' | 'series'; - selectedCategoryId: string; - selectedVodId: string; - selectedSerialId: string; - selectedItvId: string; + selectedCategoryId: string | null | undefined; + selectedVodId: string | undefined; + selectedSerialId: string | undefined; + selectedItvId: string | undefined; limit: number; page: number; searchPhrase: string; currentPlaylist: PlaylistMeta; totalCount: number; - selectedItem: any; + selectedItem: StalkerVodSource | null | undefined; vodCategories: StalkerCategoryItem[]; seriesCategories: StalkerCategoryItem[]; itvCategories: StalkerCategoryItem[]; hasMoreChannels: boolean; - itvChannels: any[]; + itvChannels: StalkerItvChannel[]; /** For VOD items that are actually series (Ministra plugin is_series=1) */ - vodSeriesSeasons: any[]; - vodSeriesEpisodes: any[]; - selectedVodSeriesSeasonId: string; + vodSeriesSeasons: StalkerVodSeriesSeason[]; + vodSeriesEpisodes: StalkerVodSeriesEpisode[]; + selectedVodSeriesSeasonId: string | undefined; } const initialState: StalkerState = { @@ -72,6 +80,8 @@ const initialState: StalkerState = { selectedVodSeriesSeasonId: undefined, }; +const logger = createLogger('StalkerStore'); + function extractNumericValue(str: string) { const matches = str.match(/\d+/); if (matches) { @@ -90,14 +100,27 @@ function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] { }); } +function sortVodSeriesSeasonsByNumber( + array: StalkerVodSeriesSeason[] +): StalkerVodSeriesSeason[] { + if (!array) return []; + return [...array].sort((a, b) => { + const numA = Number(a.season_number ?? 0) || 0; + const numB = Number(b.season_number ?? 0) || 0; + return numA - numB; + }); +} + /** * Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2") */ -function sortEpisodesByNumber(episodes: any[]): any[] { +function sortEpisodesByNumber( + episodes: StalkerVodSeriesEpisode[] +): StalkerVodSeriesEpisode[] { if (!episodes) return []; return episodes.sort((a, b) => { - const numA = parseInt(a.series_number, 10) || 0; - const numB = parseInt(b.series_number, 10) || 0; + const numA = Number(a.series_number ?? 0) || 0; + const numB = Number(b.series_number ?? 0) || 0; return numA - numB; }); } @@ -131,7 +154,10 @@ function makeAbsoluteUrl(baseUrl: string, relativePath: string): string { /** * Post-process stalker items to convert relative URLs to absolute */ -function processItemUrls(item: any, portalUrl: string): any { +function processItemUrls( + item: T, + portalUrl: string +): T { const processed = { ...item }; // Convert screenshot_uri to absolute URL @@ -145,6 +171,31 @@ function processItemUrls(item: any, portalUrl: string): any { return processed; } +function toStalkerContentItem( + item: StalkerVodSource, + portalUrl: string +): StalkerContentItem { + const processed = processItemUrls(item, portalUrl); + return { + ...processed, + cover: processed.screenshot_uri, + }; +} + +function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel { + return { + ...item, + id: item.id ?? item.stream_id ?? '', + cmd: String(item.cmd ?? ''), + name: + typeof item.name === 'string' ? item.name : undefined, + o_name: + typeof item.o_name === 'string' ? item.o_name : undefined, + logo: + typeof item.logo === 'string' ? item.logo : undefined, + }; +} + export const StalkerStore = signalStore( { providedIn: 'root' }, withState(initialState), @@ -173,7 +224,7 @@ export const StalkerStore = signalStore( serialNumber = (playlist as Playlist) .stalkerSerialNumber; } catch (error) { - console.error('Failed to get stalker token:', error); + logger.error('Failed to get stalker token', error); } } @@ -191,7 +242,7 @@ export const StalkerStore = signalStore( action: StalkerPortalActions.GetCategories, currentPlaylist: store.currentPlaylist(), }), - loader: async ({ params }) => { + loader: async ({ params }): Promise => { if (params.currentPlaylist === undefined) return; switch (params.contentType) { @@ -244,17 +295,14 @@ export const StalkerStore = signalStore( // Guard: ensure response has expected structure if (!response?.js || !Array.isArray(response.js)) { - console.warn( - '[StalkerStore] Invalid categories response:', - response - ); + logger.warn('Invalid categories response', response); return []; } const categories = response.js - .map((item) => ({ + .map((item): StalkerCategoryItem => ({ category_name: item.title, - category_id: item.id, + category_id: String(item.id), })) .sort((a, b) => a.category_name.localeCompare(b.category_name) @@ -273,7 +321,9 @@ export const StalkerStore = signalStore( search: store.searchPhrase(), pageIndex: store.page() + 1, }), - loader: async ({ params }) => { + loader: async ({ + params, + }): Promise => { if ( !params.category || params.category === null || @@ -340,10 +390,7 @@ export const StalkerStore = signalStore( // Guard: ensure response has expected structure if (!response?.js?.data) { - console.warn( - '[StalkerStore] Invalid response structure:', - response - ); + logger.warn('Invalid response structure', response); return []; } @@ -352,24 +399,21 @@ export const StalkerStore = signalStore( }); const portalUrl = currentPlaylist().portalUrl; - const newItems = response.js.data.map((item) => { - // Post-process to convert relative URLs to absolute - const processed = processItemUrls(item, portalUrl); - return { - ...processed, - cover: processed.screenshot_uri, - }; - }); + const newItems = response.js.data.map( + (item: StalkerVodSource) => + toStalkerContentItem(item, portalUrl) + ); if (store.selectedContentType() === 'itv') { + const channels = newItems.map(toStalkerItvChannel); // Check if we're loading the first page or loading more if (params.pageIndex === 1) { - patchState(store, { itvChannels: newItems }); + patchState(store, { itvChannels: channels }); } else { patchState(store, { itvChannels: [ ...store.itvChannels(), - ...newItems, + ...channels, ], }); } @@ -424,10 +468,7 @@ export const StalkerStore = signalStore( // Guard: ensure response has expected structure if (!response?.js?.data) { - console.warn( - '[StalkerStore] Invalid seasons response:', - response - ); + logger.warn('Invalid seasons response', response); return []; } return sortByNumericValue(response.js.data); @@ -441,17 +482,24 @@ export const StalkerStore = signalStore( params: () => ({ selectedItem: store.selectedItem(), }), - loader: async ({ params }) => { + loader: async ({ + params, + }): Promise => { const item = params.selectedItem; - // Debug logging - console.log('[StalkerStore] vodSeriesSeasonsResource loader called'); - console.log('[StalkerStore] item:', item); - console.log('[StalkerStore] item?.is_series:', item?.is_series); - console.log('[StalkerStore] currentPlaylist:', store.currentPlaylist()); + logger.debug( + 'vodSeriesSeasonsResource loader called', + { + item, + isSeries: item?.is_series, + currentPlaylist: store.currentPlaylist(), + } + ); // Only fetch if item is a VOD series (has is_series flag) if (!store.currentPlaylist() || !item || !item.is_series) { - console.log('[StalkerStore] vodSeriesSeasonsResource - early return, conditions not met'); + logger.debug( + 'vodSeriesSeasonsResource skipped - conditions not met' + ); return []; } @@ -482,18 +530,27 @@ export const StalkerStore = signalStore( } if (!response?.js?.data) { - console.log('[StalkerStore] vodSeriesSeasonsResource - no response data'); + logger.debug( + 'vodSeriesSeasonsResource - no response data' + ); return []; } - console.log('[StalkerStore] vodSeriesSeasonsResource - response.js.data:', response.js.data); + logger.debug( + 'vodSeriesSeasonsResource response data', + response.js.data + ); // Filter for season items (is_season: true) const seasons = response.js.data.filter( - (item: any) => item.is_season === true + (item: StalkerVodSeriesSeason) => + item.is_season === true ); - console.log('[StalkerStore] vodSeriesSeasonsResource - filtered seasons:', seasons); - return sortByNumericValue(seasons); + logger.debug( + 'vodSeriesSeasonsResource filtered seasons', + seasons + ); + return sortVodSeriesSeasonsByNumber(seasons); }, }), }) @@ -616,19 +673,28 @@ export const StalkerStore = signalStore( }); } } catch (error) { - console.error( - 'Error syncing Stalker playlist to SQLite:', + logger.error( + 'Error syncing Stalker playlist to SQLite', error ); } } }, - setSelectedItem(selectedItem: any) { + setSelectedItem(selectedItem: StalkerVodSource | null | undefined) { // TODO: check the item type and proper property either selectedVodId or serialsId etc + const selectedIdRaw = + selectedItem?.id !== undefined && + selectedItem?.id !== null + ? selectedItem.id + : undefined; + const selectedId = + selectedIdRaw !== undefined + ? normalizeStalkerEntityId(selectedIdRaw) + : undefined; patchState(store, { - selectedVodId: selectedItem?.id ?? undefined, - selectedSerialId: selectedItem?.id ?? undefined, - selectedItvId: selectedItem?.id ?? undefined, + selectedVodId: selectedId, + selectedSerialId: selectedId, + selectedItvId: selectedId, selectedItem, }); }, @@ -640,7 +706,10 @@ export const StalkerStore = signalStore( selectedItem: undefined, }); }, - setCategories(type: 'vod' | 'series' | 'itv', categories: any[]) { + setCategories( + type: 'vod' | 'series' | 'itv', + categories: StalkerCategoryItem[] + ) { if (type === 'vod') { patchState(store, { vodCategories: categories }); } else if (type === 'series') { @@ -656,7 +725,7 @@ export const StalkerStore = signalStore( itvCategories: [], }); }, - setItvChannels(channels: any[]) { + setItvChannels(channels: StalkerItvChannel[]) { patchState(store, { itvChannels: channels }); }, setSearchPhrase(phrase: string) { @@ -671,7 +740,7 @@ export const StalkerStore = signalStore( async fetchVodSeriesEpisodes( videoId: string, seasonId: string - ): Promise { + ): Promise { const playlist = store.currentPlaylist() as Playlist; if (!playlist) return []; @@ -704,7 +773,8 @@ export const StalkerStore = signalStore( // Filter for episode items (is_episode: true) and sort by series_number const episodes = sortEpisodesByNumber( response.js.data.filter( - (item: any) => item.is_episode === true + (item: StalkerVodSeriesEpisode) => + item.is_episode === true ) ); @@ -788,7 +858,7 @@ export const StalkerStore = signalStore( // Check for server-side errors if (response.js?.error) { const errorMsg = response.js.error; - console.error('[StalkerStore] Server error:', errorMsg); + logger.error('Server error', errorMsg); throw new Error(errorMsg); } @@ -888,11 +958,11 @@ export const StalkerStore = signalStore( return 'Unknown'; } catch (error) { - console.error('Failed to fetch expire date:', error); + logger.error('Failed to fetch expire date', error); return 'Error fetching data'; } }, - addToFavorites(item: any) { + addToFavorites(item: any, onDone?: () => void) { playlistService .addPortalFavorite(this.currentPlaylist()?._id, { ...item, @@ -908,9 +978,10 @@ export const StalkerStore = signalStore( duration: 1000, } ); + onDone?.(); }); }, - removeFromFavorites(favoriteId: string) { + removeFromFavorites(favoriteId: string, onDone?: () => void) { playlistService .removeFromPortalFavorites( this.currentPlaylist()?._id, @@ -924,6 +995,7 @@ export const StalkerStore = signalStore( duration: 1000, } ); + onDone?.(); }); }, /** @@ -985,6 +1057,10 @@ export const StalkerStore = signalStore( const item = this.selectedItem(); let cmdToUse = cmd ?? item?.cmd; + if (!cmdToUse) { + throw new Error('nothing_to_play'); + } + // For items with has_files and relative path, we need to fetch the file id first if ( item?.has_files !== undefined && @@ -993,7 +1069,9 @@ export const StalkerStore = signalStore( cmdToUse.includes('/media/') && !cmdToUse.includes('/media/file_') ) { - const fileId = await this.fetchMovieFileId(item.id); + const fileId = await this.fetchMovieFileId( + normalizeStalkerEntityId(item.id) + ); if (fileId) { cmdToUse = `/media/file_${fileId}.mpg`; } @@ -1007,19 +1085,24 @@ export const StalkerStore = signalStore( ); this.addToRecentlyViewed({ ...item, - id: item.id, + id: item?.id, cmd: cmd, cover: thumbnail, title, }); const playlist = this.currentPlaylist(); const isEpisode = episodeNum !== undefined || episodeId !== undefined; + const selectedItemId = + normalizeStalkerEntityIdAsNumber(item?.id) ?? 0; const contentInfo = { playlistId: playlist._id, // For episodes, use episodeId if provided, otherwise fall back to item.id - contentXtreamId: isEpisode && episodeId ? episodeId : Number(item.id), + contentXtreamId: + isEpisode && episodeId + ? episodeId + : selectedItemId, contentType: isEpisode ? 'episode' : 'vod', - seriesXtreamId: isEpisode ? Number(item.id) : undefined, + seriesXtreamId: isEpisode ? selectedItemId : undefined, }; playerService.openPlayer( @@ -1035,10 +1118,7 @@ export const StalkerStore = signalStore( startTime ); } catch (error) { - console.error( - '[StalkerStore] Failed to get playback URL:', - error - ); + logger.error('Failed to get playback URL', error); const errorMessage = error?.message === 'nothing_to_play' ? translate.instant('PORTALS.CONTENT_NOT_AVAILABLE') @@ -1055,13 +1135,20 @@ export const StalkerStore = signalStore( }) .subscribe(); }, - removeFromRecentlyViewed(itemId: number) { + removeFromRecentlyViewed(itemId: number, onComplete?: () => void) { playlistService .removeFromPortalRecentlyViewed( this.currentPlaylist()?._id, itemId ) - .subscribe(); + .subscribe({ + next: () => onComplete?.(), + error: (error) => + logger.error( + 'Failed to remove item from recently viewed', + error + ), + }); }, async fetchChannelEpg( channelId: number | string, diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html index c3f46a208..0aa387daa 100644 --- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html +++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html @@ -11,6 +11,7 @@ @if (vodDetailsItem()) { void) | null = null; readonly contentType = this.store.selectedContentType; + private readonly playlistService = inject(PlaylistsService); + private readonly favoritesRefresh = createRefreshTrigger(); readonly limit = this.store.limit; readonly pageIndex = this.store.page; @@ -190,6 +202,23 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { return position?.positionSeconds ?? null; }); + readonly portalFavorites = createPortalFavoritesResource( + this.playlistService, + () => { + if (!this.isStalker) return undefined; + const stalkerStore = this.store as InstanceType; + return stalkerStore.currentPlaylist()?._id; + }, + () => this.favoritesRefresh.refreshVersion() + ); + + readonly isSelectedVodFavorite = computed(() => + isSelectedStalkerVodFavorite( + this.vodDetailsItem(), + this.portalFavorites.value() ?? [] + ) + ); + seasons = []; ngOnInit() { @@ -279,34 +308,11 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { } onItemClick(item: any) { - const selectedItem = { - id: item.id, - cmd: item.cmd, - // For VOD items with embedded series array (Stalker vclub) - series: item.series, - // Preserve has_files for cmd transformation during playback - has_files: item.has_files, - // Flag for VOD items that are actually series (Ministra plugin) - // is_series can be "1" (string) or 1 (number) - // ONLY set this for VOD content type - regular series should use the standard series flow - is_series: - this.contentType() === 'vod' && - (item.is_series === '1' || item.is_series === 1), - // Store video_id for season fetching if available - video_id: item.video_id, - info: { - movie_image: item.screenshot_uri, - description: item.description, - name: item.o_name || item.name, - o_name: item.o_name, - director: item.director, - releasedate: item.year, - genre: item.genres_str, - actors: item.actors, - rating_imdb: item.rating_imdb, - rating_kinopoisk: item.rating_kinopoisk, - }, - }; + const selectedItem = buildStalkerSelectedVodItem( + item, + this.contentType() === 'vod' && + (item.is_series === '1' || item.is_series === 1) + ); if (this.isStalker) { this.store.setSelectedItem(selectedItem); @@ -333,14 +339,30 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { await this.store.createLinkToPlayVod(cmd, title, thumbnail); } - addToFavorites(item: any) { - console.debug('Add to favorites', item); + addToFavorites(item: any, onDone?: () => void) { + this.logger.debug('Add to favorites', item); + if (this.isStalker) { + (this.store as InstanceType).addToFavorites( + item, + onDone + ); + return; + } this.store.addToFavorites(item); + onDone?.(); } - removeFromFavorites(favoriteId: string) { - console.debug('Remove from favorites', favoriteId); + removeFromFavorites(favoriteId: string, onDone?: () => void) { + this.logger.debug('Remove from favorites', favoriteId); + if (this.isStalker) { + (this.store as InstanceType).removeFromFavorites( + favoriteId, + onDone + ); + return; + } this.store.removeFromFavorites(favoriteId); + onDone?.(); } /** Handle play from vod-details component */ @@ -374,19 +396,14 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { item: VodDetailsItem; isFavorite: boolean; }): void { - if (event.item.type === 'stalker') { - if (event.isFavorite) { - this.addToFavorites({ - ...event.item.data, - category_id: 'vod', - title: event.item.data.info?.name, - cover: event.item.data.info?.movie_image, - added_at: new Date().toISOString(), - }); - } else { - this.removeFromFavorites(event.item.data.id); - } - } + toggleStalkerVodFavorite(event, { + addToFavorites: (item, onDone) => this.addToFavorites(item, onDone), + removeFromFavorites: (favoriteId, onDone) => + this.removeFromFavorites(favoriteId, onDone), + onComplete: () => { + this.favoritesRefresh.refresh(); + }, + }); } /** Handle back from vod-details component */ diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index fecbd6e3e..4ed395832 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -221,13 +221,27 @@ export class PlaylistsService { this.dbService.update(DbStores.Playlists, { ...portal, favorites: portal.favorites?.filter( - (i) => - (i as Partial).stream_id !== - favoriteId && - (i as Partial).series_id !== - favoriteId && - (i as Partial<{ movie_id: string }>).movie_id !== - favoriteId + (i) => { + const expectedId = String(favoriteId); + const streamId = String( + (i as Partial).stream_id ?? '' + ); + const seriesId = String( + (i as Partial).series_id ?? '' + ); + const movieId = String( + (i as Partial<{ movie_id: string }>).movie_id ?? + '' + ); + const itemId = String((i as any).id ?? ''); + + return ( + streamId !== expectedId && + seriesId !== expectedId && + movieId !== expectedId && + itemId !== expectedId + ); + } ), }) )