From b5bd1bd92aab1cbdb4eb4fc8cbc929e28920bf15 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Mar 2026 21:21:17 +0100 Subject: [PATCH] refactor(portals): create nx feature libraries for xtream and stalker - Added StalkerCatalogFacadeService to manage Stalker catalog operations. - Created stalker-feature-loaders to define route options for Stalker features. - Removed deprecated stalker.routes.ts file. - Updated shared utilities to include portal catalog facade interfaces. feat(xtream): implement XtreamCatalogFacadeService and related feature loaders - Added XtreamCatalogFacadeService to manage Xtream catalog operations. - Created xtream-feature-loaders to define route options for Xtream features. - Removed deprecated xtream.routes.ts file. - Updated shared utilities to include portal catalog facade interfaces. refactor: organize portal feature libraries - Created separate feature libraries for Stalker and Xtream. - Defined project structure and TypeScript configurations for both libraries. - Implemented routing configurations for Stalker and Xtream features. --- apps/web/src/app/app.routes.ts | 14 +- .../category-content-view.component.html | 8 +- .../category-content-view.component.ts | 382 ++++++------------ .../stalker/stalker-catalog-facade.service.ts | 168 ++++++++ .../app/stalker/stalker-feature-loaders.ts | 35 ++ apps/web/src/app/stalker/stalker.routes.ts | 121 ------ .../xtream-catalog-facade.service.ts | 144 +++++++ .../xtream-electron/xtream-feature-loaders.ts | 48 +++ .../src/app/xtream-electron/xtream.routes.ts | 129 ------ libs/portal/shared/util/src/index.ts | 1 + .../util/src/lib/portal-catalog-facade.ts | 93 +++++ libs/portal/stalker/feature/project.json | 13 + libs/portal/stalker/feature/src/index.ts | 1 + .../feature/src/lib/stalker-feature.routes.ts | 113 ++++++ libs/portal/stalker/feature/tsconfig.json | 28 ++ libs/portal/stalker/feature/tsconfig.lib.json | 12 + libs/portal/xtream/feature/project.json | 13 + libs/portal/xtream/feature/src/index.ts | 1 + .../feature/src/lib/xtream-feature.routes.ts | 115 ++++++ libs/portal/xtream/feature/tsconfig.json | 28 ++ libs/portal/xtream/feature/tsconfig.lib.json | 12 + tsconfig.base.json | 6 + 22 files changed, 973 insertions(+), 512 deletions(-) create mode 100644 apps/web/src/app/stalker/stalker-catalog-facade.service.ts create mode 100644 apps/web/src/app/stalker/stalker-feature-loaders.ts delete mode 100644 apps/web/src/app/stalker/stalker.routes.ts create mode 100644 apps/web/src/app/xtream-electron/xtream-catalog-facade.service.ts create mode 100644 apps/web/src/app/xtream-electron/xtream-feature-loaders.ts delete mode 100644 apps/web/src/app/xtream-electron/xtream.routes.ts create mode 100644 libs/portal/shared/util/src/lib/portal-catalog-facade.ts create mode 100644 libs/portal/stalker/feature/project.json create mode 100644 libs/portal/stalker/feature/src/index.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts create mode 100644 libs/portal/stalker/feature/tsconfig.json create mode 100644 libs/portal/stalker/feature/tsconfig.lib.json create mode 100644 libs/portal/xtream/feature/project.json create mode 100644 libs/portal/xtream/feature/src/index.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts create mode 100644 libs/portal/xtream/feature/tsconfig.json create mode 100644 libs/portal/xtream/feature/tsconfig.lib.json diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 43b57eec9..5e610c435 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -1,6 +1,8 @@ import { Route, Routes } from '@angular/router'; -import { stalkerRoutes } from './stalker/stalker.routes'; -import { xtreamRoutes } from './xtream-electron/xtream.routes'; +import { createStalkerRoutes } from '@iptvnator/portal/stalker/feature'; +import { createXtreamRoutes } from '@iptvnator/portal/xtream/feature'; +import { stalkerFeatureRouteOptions } from './stalker/stalker-feature-loaders'; +import { xtreamFeatureRouteOptions } from './xtream-electron/xtream-feature-loaders'; function withWorkspaceLayout(routes: Route[]): Route[] { return routes.map((route) => ({ @@ -80,8 +82,12 @@ export const routes: Routes = [ (c) => c.DownloadsComponent ), }, - ...withWorkspaceLayout(xtreamRoutes), - ...withWorkspaceLayout(stalkerRoutes), + ...withWorkspaceLayout( + createXtreamRoutes(xtreamFeatureRouteOptions) + ), + ...withWorkspaceLayout( + createStalkerRoutes(stalkerFeatureRouteOptions) + ), { path: 'settings', data: { layout: 'workspace' }, diff --git a/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.html b/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.html index be535e021..5568fafca 100644 --- a/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.html +++ b/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.html @@ -1,10 +1,10 @@ @if (selectedCategory()) { - @if (isStalker && selectedItem()) { + @if (isStalker && selectedStalkerItem()) { @if (contentType() === 'vod') { - @if (selectedItem().series?.length > 0) { + @if (selectedStalkerItem().series?.length > 0) { - - } @else if (selectedItem().is_series) { + + } @else if (selectedStalkerItem().is_series) { } @else { diff --git a/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.ts b/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.ts index 6d86141c2..449228f32 100644 --- a/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.ts +++ b/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.ts @@ -14,7 +14,6 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; -import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from 'services'; import { @@ -24,31 +23,32 @@ import { StalkerVodDetails, VodDetailsItem, } from 'shared-interfaces'; -import { DownloadsService } from '../../services/downloads.service'; -import { ExternalPlaybackService } from '../../services/external-playback.service'; -import { PlayerService } from '../../services/player.service'; -import { GridListComponent } from '../../shared/components/grid-list/grid-list.component'; import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui'; -import { createLogger } from '@iptvnator/portal/shared/util'; -import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access'; -import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component'; import { - buildStalkerSelectedVodItem, + createLogger, + isStalkerPortalCatalogFacade, + PortalCatalogFacade, + PortalCatalogSortMode, + PORTAL_CATALOG_FACADE, + StalkerPortalCatalogFacade, +} from '@iptvnator/portal/shared/util'; +import { createPortalFavoritesResource, createRefreshTrigger, isSelectedStalkerVodFavorite, normalizeStalkerEntityId, normalizeStalkerEntityIdAsNumber, + StalkerSelectedVodItem, toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; -import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; -import { XtreamCategorySortMode } from '@iptvnator/portal/xtream/data-access'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { DownloadsService } from '../../services/downloads.service'; +import { ExternalPlaybackService } from '../../services/external-playback.service'; +import { PlayerService } from '../../services/player.service'; +import { GridListComponent } from '../../shared/components/grid-list/grid-list.component'; +import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component'; import { VodDetailsComponent } from '../vod-details/vod-details.component'; -const SORT_STORAGE_KEY = 'xtream-category-sort-mode'; - interface CategoryContentItem { id?: number | string; is_series?: number | string | boolean; @@ -59,17 +59,6 @@ interface CategoryContentItem { [key: string]: unknown; } -interface CategoryStoreLike { - totalCount?: () => number; - selectItemsFromSelectedCategory?: () => unknown[]; - getProgressPercent?: (id: number, type: 'vod') => number; - hasSeriesProgress?: (id: number) => boolean; - isWatched?: (id: number, type: 'vod') => boolean; - currentPlaylist?: () => { id?: string } | null; - loadAllPositions?: (playlistId: string) => void; - selectedCategoryId?: () => number | null; -} - interface DownloadVodData { id?: string | number; has_files?: unknown; @@ -105,62 +94,47 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { readonly externalPlayback = inject(ExternalPlaybackService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); - - readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker'; + private readonly playlistService = inject(PlaylistsService); + private readonly downloadsService = inject(DownloadsService); + private readonly catalog = inject( + PORTAL_CATALOG_FACADE + ) as PortalCatalogFacade< + CategoryContentItem, + CategoryContentItem, + CategoryContentItem + >; + private readonly stalkerCatalog: + | StalkerPortalCatalogFacade< + CategoryContentItem, + CategoryContentItem, + CategoryContentItem + > + | null = isStalkerPortalCatalogFacade(this.catalog) + ? (this.catalog as StalkerPortalCatalogFacade< + CategoryContentItem, + CategoryContentItem, + CategoryContentItem + >) + : null; private readonly logger = createLogger('CategoryContentView'); - private readonly store = this.isStalker - ? inject(StalkerStore) - : inject(XtreamStore); + private readonly favoritesRefresh = createRefreshTrigger(); + + readonly isStalker = this.catalog.provider === 'stalker'; /** Unsubscribe function for playback position updates */ private unsubscribePositionUpdates: (() => void) | null = null; - readonly contentType = this.store.selectedContentType; - private readonly playlistService = inject(PlaylistsService); - private readonly downloadsService = inject(DownloadsService); - private readonly favoritesRefresh = createRefreshTrigger(); + readonly contentType = this.catalog.contentType; readonly inlinePlayback = signal(null); private lastInlineSaveTime = 0; - readonly limit = this.store.limit; - readonly pageIndex = this.store.page; - readonly pageSizeOptions = this.isStalker ? [14] : [10, 25, 50, 100]; - readonly selectedCategory = this.store.getSelectedCategory; - readonly paginatedContent = this.store.getPaginatedContent; - readonly selectedCategoryTitle = computed(() => { - const category = this.selectedCategory(); - if (!category) return ''; - - // category_name is the StalkerCategoryItem field; name is the Xtream field - const fromCategory = - category.category_name ?? category.name ?? category.title ?? ''; - if (fromCategory) return fromCategory; - - // Stalker: fall back to getSelectedCategoryName computed (looks up directly from categories array) - if (this.isStalker) { - const stalkerStore = this.store as unknown as { - getSelectedCategoryName?: () => string; - }; - const fromStoreComputed = - stalkerStore.getSelectedCategoryName?.() ?? ''; - if (fromStoreComputed) return fromStoreComputed; - } - - return ''; - }); - readonly categoryItemCount = computed(() => { - const storeLike = this.store as unknown as CategoryStoreLike; - - if (this.isStalker && typeof storeLike.totalCount === 'function') { - return storeLike.totalCount(); - } - - if (typeof storeLike.selectItemsFromSelectedCategory === 'function') { - return storeLike.selectItemsFromSelectedCategory().length; - } - - return 0; - }); + readonly limit = this.catalog.limit; + readonly pageIndex = this.catalog.pageIndex; + readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions); + readonly selectedCategory = this.catalog.selectedCategory; + readonly paginatedContent = this.catalog.paginatedContent; + readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle; + readonly categoryItemCount = this.catalog.categoryItemCount; readonly categoryItemSubtitle = computed(() => { const itemCount = this.categoryItemCount(); return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`; @@ -178,14 +152,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { private loadedPositionsForPlaylistId: string | null = null; constructor() { - // Effect to load Stalker positions when playlist becomes available if (this.isStalker) { effect(() => { - const stalkerStore = this.store as InstanceType< - typeof StalkerStore - >; - const playlist = stalkerStore.currentPlaylist(); - const playlistId = playlist?._id; + const playlistId = this.catalog.playlist()?.id; if ( playlistId && @@ -196,20 +165,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { } }); - // Effect to load position for selected VOD item (ensures fresh data after playback) effect(() => { - const stalkerStore = this.store as InstanceType< - typeof StalkerStore - >; const item = this.selectedItem(); - const playlist = stalkerStore.currentPlaylist(); + const playlistId = this.catalog.playlist()?.id; - if (item && playlist?._id && this.contentType() === 'vod') { - this.loadSingleVodPosition(playlist._id, Number(item.id)); + if (item && playlistId && this.contentType() === 'vod') { + this.loadSingleVodPosition(playlistId, Number(item.id)); } }); - // Listen for real-time playback position updates from video player if (window.electron?.onPlaybackPositionUpdate) { this.unsubscribePositionUpdates = window.electron.onPlaybackPositionUpdate( @@ -237,25 +201,19 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { } readonly contentWithProgress = computed(() => { - const items = this.store.getPaginatedContent(); + const items = this.paginatedContent(); if (!items) return []; if (this.isStalker) { - // Enrich Stalker items with progress data const positions = this.stalkerPositions(); const seriesPositions = this.stalkerSeriesPositions(); const contentType = this.contentType(); return items.map((item: CategoryContentItem) => { const id = Number(item.id); - - // Check if this item has series/episode progress (watched episodes) - // This works even when is_series flag isn't set on the item const hasSeriesProgress = Boolean( seriesPositions.get(id)?.length ); - - // For series content type, or items with is_series flag const isSeries = contentType === 'series' || item.is_series === '1' || @@ -263,73 +221,59 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { if (hasSeriesProgress) { return { ...item, hasSeriesProgress: true }; - } else if (isSeries) { - return { ...item, hasSeriesProgress: false }; - } else { - // Regular VOD item - const vodKey = `vod_${id}`; - const vodPosition = positions.get(vodKey); - const vodProgress = this.calculateProgress(vodPosition); - return { - ...item, - progress: vodProgress, - isWatched: vodProgress >= 90, - }; } + + if (isSeries) { + return { ...item, hasSeriesProgress: false }; + } + + const vodKey = `vod_${id}`; + const vodPosition = positions.get(vodKey); + const vodProgress = this.calculateProgress(vodPosition); + return { + ...item, + progress: vodProgress, + isWatched: vodProgress >= 90, + }; }); } - const xtreamStore = this.store as unknown as CategoryStoreLike; - if (!xtreamStore.getProgressPercent) return items; - - return items.map((item: CategoryContentItem) => { - const isSeries = this.contentType() === 'series'; - const id = Number( - item.xtream_id || item.series_id || item.stream_id - ); - - if (isSeries) { - return { - ...item, - hasSeriesProgress: xtreamStore.hasSeriesProgress(id), - }; - } else { - return { - ...item, - progress: xtreamStore.getProgressPercent(id, 'vod'), - isWatched: xtreamStore.isWatched(id, 'vod'), - }; - } - }); + return items.map((item: CategoryContentItem) => ({ + ...item, + ...this.catalog.getItemProgress(item), + })); }); - readonly isPaginatedContentLoading = this.store.isPaginatedContentLoading; - readonly selectedItem = this.store.selectedItem; - readonly totalPages = this.store.getTotalPages; - readonly bigStore = inject(Store); - readonly contentSortMode = !this.isStalker - ? (this.store as unknown as InstanceType) - .contentSortMode - : signal('date-desc'); + readonly isPaginatedContentLoading = + this.catalog.isPaginatedContentLoading; + readonly selectedItem = this.catalog.selectedItem; + readonly totalPages = this.catalog.totalPages; + readonly contentSortMode = this.catalog.contentSortMode; + readonly selectedStalkerItem = computed( + () => { + if (!this.isStalker) { + return null; + } + + return (this.selectedItem() as unknown as StalkerSelectedVodItem | null) ?? null; + } + ); /** Computed VodDetailsItem for the vod-details component */ readonly vodDetailsItem = computed(() => { - const item = this.selectedItem(); + const item = this.selectedStalkerItem(); if (!item || !this.isStalker) return null; - // Access currentPlaylist from the store (type-safe since we're in stalker mode) - const stalkerStore = this.store as unknown as { - currentPlaylist: () => { _id: string } | null; - }; + return createStalkerVodItem( - item as StalkerVodDetails, - stalkerStore.currentPlaylist()?._id ?? '' + item as unknown as StalkerVodDetails, + this.catalog.playlist()?.id ?? '' ); }); /** Playback position for the selected VOD item (Stalker) */ readonly selectedVodPlaybackPosition = computed(() => { if (!this.isStalker) return null; - const item = this.selectedItem(); + const item = this.selectedStalkerItem(); if (!item) return null; const id = Number(item.id); @@ -341,13 +285,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { readonly portalFavorites = createPortalFavoritesResource( this.playlistService, - () => { - if (!this.isStalker) return undefined; - const stalkerStore = this.store as InstanceType< - typeof StalkerStore - >; - return stalkerStore.currentPlaylist()?._id; - }, + () => (this.isStalker ? this.catalog.playlist()?.id : undefined), () => this.favoritesRefresh.refreshVersion() ); @@ -360,60 +298,23 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { seasons = []; - setContentSortMode(mode: XtreamCategorySortMode): void { - ( - this.store as unknown as InstanceType - ).setContentSortMode(mode); - localStorage.setItem(SORT_STORAGE_KEY, mode); + setContentSortMode(mode: PortalCatalogSortMode): void { + this.catalog.setContentSortMode(mode); } ngOnInit() { const { categoryId } = this.activatedRoute.snapshot.params; - - // Ensure playback positions are loaded (Xtream only - Stalker is handled via effect in constructor) - if (!this.isStalker) { - const savedSortMode = localStorage.getItem(SORT_STORAGE_KEY); - if ( - savedSortMode === 'date-desc' || - savedSortMode === 'date-asc' || - savedSortMode === 'name-asc' || - savedSortMode === 'name-desc' - ) { - ( - this.store as unknown as InstanceType - ).setContentSortMode(savedSortMode); - } - const xtreamStore = this.store as unknown as CategoryStoreLike; - const playlistId = xtreamStore.currentPlaylist?.()?.id; - if (playlistId) { - xtreamStore.loadAllPositions?.(playlistId); - } - } - - // Clear any previous selectedItem when entering category view - // This ensures the content-header is visible - this.store.setSelectedItem(null); - - // Set category - setSelectedCategory only resets page if category actually changes - // This preserves the page state when navigating back from detail view - if (categoryId) { - this.store.setSelectedCategory(categoryId); - } else { - // Stalker uses "*" as the "All" category id - if (this.isStalker) { - ( - this.store as InstanceType - ).setSelectedCategory('*'); - } else { - this.store.setSelectedCategory(null); - } - } + this.catalog.initialize(categoryId ?? null); if (this.isStalker) { - const storeLike = this.store as unknown as CategoryStoreLike; + const selectedCategory = + (this.selectedCategory() as CategoryContentItem | null) ?? null; this.logger.warn('Stalker category init from route', { routeCategoryId: categoryId ?? null, - selectedCategoryId: storeLike.selectedCategoryId?.(), + selectedCategoryId: + selectedCategory?.category_id ?? + selectedCategory?.id ?? + null, selectedContentType: this.contentType(), }); } @@ -433,7 +334,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { const key = `${pos.contentType}_${pos.contentXtreamId}`; positionsMap.set(key, pos); - // Group episodes by series ID if (pos.contentType === 'episode' && pos.seriesXtreamId) { const existing = seriesMap.get(pos.seriesXtreamId) || []; existing.push(pos); @@ -477,7 +377,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { const percent = (position.positionSeconds / position.durationSeconds) * 100; - // If watched > 10s but percent < 1, return 1 to show visual progress if (position.positionSeconds > 10 && percent < 1) { return 1; } @@ -486,36 +385,16 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { } onPageChange(event: PageEvent) { - this.store.setPage(event.pageIndex); - this.store.setLimit(event.pageSize); - localStorage.setItem('xtream-page-size', event.pageSize.toString()); + this.catalog.setPage(event.pageIndex); + this.catalog.setLimit(event.pageSize); } onItemClick(item: CategoryContentItem) { - const selectedItem = buildStalkerSelectedVodItem( - item as StalkerVodSource, - this.contentType() === 'vod' && - (item.is_series === '1' || item.is_series === 1) - ); - - if (this.isStalker) { - this.store.setSelectedItem(selectedItem); - } else { - const xtreamStore = this.store as unknown as CategoryStoreLike; - // When viewing "Recently Added" (no category selected), include category_id in path - const categoryId = xtreamStore.selectedCategoryId?.(); - if (categoryId && item.xtream_id !== undefined) { - this.router.navigate([item.xtream_id], { - relativeTo: this.activatedRoute, - }); - } else if ( - item.category_id !== undefined && - item.xtream_id !== undefined - ) { - this.router.navigate([item.category_id, item.xtream_id], { - relativeTo: this.activatedRoute, - }); - } + const navigation = this.catalog.selectItem(item); + if (navigation?.length) { + this.router.navigate(navigation, { + relativeTo: this.activatedRoute, + }); } } @@ -524,31 +403,30 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { title?: string, thumbnail?: string ) { - await this.store.createLinkToPlayVod(cmd, title, thumbnail); + if (!this.stalkerCatalog) { + return; + } + + await this.stalkerCatalog.createLinkToPlayVod(cmd, title, thumbnail); } addToFavorites(item: Record, onDone?: () => void) { this.logger.debug('Add to favorites', item); - if (this.isStalker) { - (this.store as InstanceType).addToFavorites( - item, - onDone - ); + if (this.stalkerCatalog) { + this.stalkerCatalog.addToFavorites(item, onDone); return; } - this.store.addToFavorites(item); + onDone?.(); } removeFromFavorites(favoriteId: string, onDone?: () => void) { this.logger.debug('Remove from favorites', favoriteId); - if (this.isStalker) { - ( - this.store as InstanceType - ).removeFromFavorites(favoriteId, onDone); + if (this.stalkerCatalog) { + this.stalkerCatalog.removeFromFavorites(favoriteId, onDone); return; } - this.store.removeFromFavorites(favoriteId); + onDone?.(); } @@ -596,7 +474,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { /** Handle back from vod-details component */ onVodBack(): void { this.closeInlinePlayer(); - this.store.setSelectedItem(null); + this.catalog.clearSelectedItem(); } handleInlineTimeUpdate(event: { @@ -644,17 +522,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { /** Handle download from vod-details component */ async onVodDownload(item: VodDetailsItem): Promise { - if (item.type !== 'stalker') return; + if (item.type !== 'stalker' || !this.stalkerCatalog) return; - const stalkerStore = this.store as InstanceType; - const playlist = stalkerStore.currentPlaylist(); + const playlist = this.catalog.playlist(); if (!playlist || !playlist.portalUrl || !playlist.macAddress) return; let cmdToUse = item.cmd; const itemData = item.data as DownloadVodData; const normalizedItemId = normalizeStalkerEntityId(itemData?.id); - // Align with playback path: resolve has_files references to /media/file_{id}.mpg if ( itemData?.has_files !== undefined && cmdToUse && @@ -663,13 +539,13 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { !cmdToUse.includes('/media/file_') ) { const fileId = - await stalkerStore.fetchMovieFileId(normalizedItemId); + await this.stalkerCatalog.fetchMovieFileId(normalizedItemId); if (fileId) { cmdToUse = `/media/file_${fileId}.mpg`; } } - const url = await stalkerStore.fetchLinkToPlay( + const url = await this.stalkerCatalog.fetchLinkToPlay( playlist.portalUrl, playlist.macAddress, cmdToUse @@ -679,7 +555,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0; await this.downloadsService.startDownload({ - playlistId: playlist._id, + playlistId: playlist.id, xtreamId: numericId, contentType: 'vod', title: itemData?.info?.name || itemData?.title || 'Unknown', @@ -687,10 +563,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { posterUrl: itemData?.info?.movie_image, headers: { userAgent: playlist.userAgent, - referer: playlist.referrer, + referer: playlist.referer, origin: playlist.origin, }, - // Playlist info for auto-creation (Stalker playlists) playlistName: playlist.title || 'Stalker Portal', playlistType: 'stalker', portalUrl: playlist.portalUrl, @@ -709,16 +584,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { thumbnail?: string, startTime?: number ): Promise { + if (!this.stalkerCatalog) { + return; + } + try { - const stalkerStore = this.store as InstanceType< - typeof StalkerStore - >; - const playback = await stalkerStore.resolveVodPlayback( + const playback = await this.stalkerCatalog.resolveVodPlayback( cmd, title, thumbnail, - undefined, - undefined, startTime ); diff --git a/apps/web/src/app/stalker/stalker-catalog-facade.service.ts b/apps/web/src/app/stalker/stalker-catalog-facade.service.ts new file mode 100644 index 000000000..f3b83a7c2 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-catalog-facade.service.ts @@ -0,0 +1,168 @@ +import { Provider, Injectable, computed, inject } from '@angular/core'; +import { + buildStalkerSelectedVodItem, + StalkerStore, + StalkerVodSource, +} from '@iptvnator/portal/stalker/data-access'; +import { + PortalCatalogItemProgress, + PortalCatalogPlaylistMeta, + PortalCatalogSortMode, + PORTAL_CATALOG_FACADE, + StalkerPortalCatalogFacade, +} from '@iptvnator/portal/shared/util'; + +@Injectable() +export class StalkerCatalogFacadeService + implements + StalkerPortalCatalogFacade< + Record, + StalkerVodSource, + StalkerVodSource + > +{ + private readonly stalkerStore = inject(StalkerStore); + + readonly provider = 'stalker' as const; + readonly pageSizeOptions = [14] as const; + readonly contentType = this.stalkerStore.selectedContentType; + readonly limit = this.stalkerStore.limit; + readonly pageIndex = this.stalkerStore.page; + readonly selectedCategory = this.stalkerStore.getSelectedCategory; + readonly paginatedContent = computed( + () => this.stalkerStore.getPaginatedContent() ?? [] + ); + readonly selectedItem = this.stalkerStore.selectedItem; + readonly totalPages = this.stalkerStore.getTotalPages; + readonly isPaginatedContentLoading = + this.stalkerStore.isPaginatedContentLoading; + readonly selectedCategoryTitle = computed(() => { + const category = this.selectedCategory(); + const fromCategory = String( + category?.['category_name'] ?? category?.['name'] ?? '' + ); + + if (fromCategory) { + return fromCategory; + } + + return this.stalkerStore.getSelectedCategoryName() ?? ''; + }); + readonly categoryItemCount = computed(() => this.stalkerStore.totalCount()); + readonly contentSortMode = computed( + () => null + ); + readonly playlist = computed(() => { + const playlist = this.stalkerStore.currentPlaylist(); + if (!playlist?._id) { + return null; + } + + return { + id: playlist._id, + title: playlist.title ?? 'Stalker Portal', + portalUrl: playlist.portalUrl, + macAddress: playlist.macAddress, + userAgent: playlist.userAgent, + referer: playlist.referrer, + origin: playlist.origin, + }; + }); + + initialize(categoryId?: string | null): void { + this.clearSelectedItem(); + if (categoryId) { + this.stalkerStore.setSelectedCategory(categoryId); + return; + } + + this.stalkerStore.setSelectedCategory('*'); + } + + clearSelectedItem(): void { + this.stalkerStore.clearSelectedItem(); + } + + setPage(page: number): void { + this.stalkerStore.setPage(page); + } + + setLimit(limit: number): void { + this.stalkerStore.setLimit(limit); + } + + setContentSortMode(_mode: PortalCatalogSortMode): void { + // Stalker catalog content is server-paginated and does not support local sort modes. + } + + selectItem(item: StalkerVodSource): string[] | null { + const needsSeriesFetch = + this.contentType() === 'vod' && + (item.is_series === '1' || item.is_series === 1); + + this.stalkerStore.setSelectedItem( + buildStalkerSelectedVodItem(item, needsSeriesFetch) + ); + return null; + } + + getItemProgress( + _item: StalkerVodSource + ): PortalCatalogItemProgress { + return {}; + } + + async createLinkToPlayVod( + cmd?: string, + title?: string, + thumbnail?: string + ): Promise { + await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); + } + + addToFavorites(item: Record, onDone?: () => void): void { + this.stalkerStore.addToFavorites(item, onDone); + } + + removeFromFavorites(favoriteId: string, onDone?: () => void): void { + this.stalkerStore.removeFromFavorites(favoriteId, onDone); + } + + fetchMovieFileId(itemId: string): Promise { + return this.stalkerStore.fetchMovieFileId(itemId); + } + + async fetchLinkToPlay( + portalUrl: string, + macAddress: string, + cmd: string + ): Promise { + return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd); + } + + resolveVodPlayback( + cmd?: string, + title?: string, + thumbnail?: string, + startTime?: number + ) { + return this.stalkerStore.resolveVodPlayback( + cmd, + title, + thumbnail, + undefined, + undefined, + startTime + ); + } +} + +export function provideStalkerCatalogFacade(): Provider[] { + return [ + StalkerCatalogFacadeService, + { + provide: PORTAL_CATALOG_FACADE, + useExisting: StalkerCatalogFacadeService, + }, + ]; +} diff --git a/apps/web/src/app/stalker/stalker-feature-loaders.ts b/apps/web/src/app/stalker/stalker-feature-loaders.ts new file mode 100644 index 000000000..227c37834 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-feature-loaders.ts @@ -0,0 +1,35 @@ +import { StalkerFeatureRouteOptions } from '@iptvnator/portal/stalker/feature'; +import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service'; + +// Temporary app-side loader map while Stalker route screens remain in apps/web. +export const stalkerFeatureRouteOptions: StalkerFeatureRouteOptions = { + catalogProviders: provideStalkerCatalogFacade(), + loadShellComponent: () => + import('./stalker-shell/stalker-shell.component').then( + (c) => c.StalkerShellComponent + ), + loadMainContainerComponent: () => + import('./stalker-main-container.component').then( + (c) => c.StalkerMainContainerComponent + ), + loadCategoryContentViewComponent: () => + import( + '../portal-shared/category-content-view/category-content-view.component' + ).then((c) => c.CategoryContentViewComponent), + loadLiveStreamLayoutComponent: () => + import( + './stalker-live-stream-layout/stalker-live-stream-layout.component' + ).then((c) => c.StalkerLiveStreamLayoutComponent), + loadFavoritesComponent: () => + import('./stalker-favorites/stalker-favorites.component').then( + (c) => c.StalkerFavoritesComponent + ), + loadRecentlyViewedComponent: () => + import('./recently-viewed/recently-viewed.component').then( + (c) => c.RecentlyViewedComponent + ), + loadSearchComponent: () => + import('./stalker-search/stalker-search.component').then( + (c) => c.StalkerSearchComponent + ), +}; diff --git a/apps/web/src/app/stalker/stalker.routes.ts b/apps/web/src/app/stalker/stalker.routes.ts deleted file mode 100644 index 04d9c2173..000000000 --- a/apps/web/src/app/stalker/stalker.routes.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Route } from '@angular/router'; - -export const stalkerRoutes: Route[] = [ - { - path: 'stalker/:id', - loadComponent: () => - import('./stalker-shell/stalker-shell.component').then( - (c) => c.StalkerShellComponent - ), - children: [ - { - path: '', - redirectTo: 'vod', - pathMatch: 'full', - }, - { - path: 'vod', - loadComponent: () => - import('./stalker-main-container.component').then( - (c) => c.StalkerMainContainerComponent - ), - children: [ - { - path: '', - data: { - api: 'stalker', - contentType: 'vod', - }, - loadComponent: () => - import( - '../portal-shared/category-content-view/category-content-view.component' - ).then((c) => c.CategoryContentViewComponent), - }, - { - path: ':categoryId', - data: { - api: 'stalker', - contentType: 'vod', - }, - loadComponent: () => - import( - '../portal-shared/category-content-view/category-content-view.component' - ).then((c) => c.CategoryContentViewComponent), - }, - ], - }, - - { - path: '', - redirectTo: 'vod', - pathMatch: 'full', - }, - { - path: 'itv', - loadComponent: () => - import( - './stalker-live-stream-layout/stalker-live-stream-layout.component' - ).then((c) => c.StalkerLiveStreamLayoutComponent), - }, - { - path: 'series', - loadComponent: () => - import('./stalker-main-container.component').then( - (c) => c.StalkerMainContainerComponent - ), - children: [ - { - path: '', - loadComponent: () => - import( - '../portal-shared/category-content-view/category-content-view.component' - ).then((c) => c.CategoryContentViewComponent), - data: { - api: 'stalker', - contentType: 'series', - }, - }, - { - path: ':categoryId', - loadComponent: () => - import( - '../portal-shared/category-content-view/category-content-view.component' - ).then((c) => c.CategoryContentViewComponent), - data: { - api: 'stalker', - contentType: 'series', - }, - }, - ], - }, - { - path: 'favorites', - loadComponent: () => - import( - './stalker-favorites/stalker-favorites.component' - ).then((c) => c.StalkerFavoritesComponent), - }, - { - path: 'recent', - loadComponent: () => - import('./recently-viewed/recently-viewed.component').then( - (c) => c.RecentlyViewedComponent - ), - }, - { - path: 'search', - loadComponent: () => - import('./stalker-search/stalker-search.component').then( - (c) => c.StalkerSearchComponent - ), - }, - { - path: 'downloads', - loadComponent: () => - import( - '@iptvnator/portal/downloads/feature' - ).then((c) => c.DownloadsComponent), - }, - ], - }, -]; diff --git a/apps/web/src/app/xtream-electron/xtream-catalog-facade.service.ts b/apps/web/src/app/xtream-electron/xtream-catalog-facade.service.ts new file mode 100644 index 000000000..f3434c3cb --- /dev/null +++ b/apps/web/src/app/xtream-electron/xtream-catalog-facade.service.ts @@ -0,0 +1,144 @@ +import { Provider, Injectable, computed, inject } from '@angular/core'; +import { + PortalCatalogFacade, + PortalCatalogItemProgress, + PortalCatalogPlaylistMeta, + PortalCatalogSortMode, + PORTAL_CATALOG_FACADE, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; + +const SORT_STORAGE_KEY = 'xtream-category-sort-mode'; + +@Injectable() +export class XtreamCatalogFacadeService + implements PortalCatalogFacade, Record, unknown> +{ + private readonly xtreamStore = inject(XtreamStore); + + readonly provider = 'xtream' as const; + readonly pageSizeOptions = [10, 25, 50, 100] as const; + readonly contentType = this.xtreamStore.selectedContentType; + readonly limit = this.xtreamStore.limit; + readonly pageIndex = this.xtreamStore.page; + readonly selectedCategory = this.xtreamStore.getSelectedCategory; + readonly paginatedContent = this.xtreamStore.getPaginatedContent; + readonly selectedItem = this.xtreamStore.selectedItem; + readonly totalPages = this.xtreamStore.getTotalPages; + readonly isPaginatedContentLoading = + this.xtreamStore.isPaginatedContentLoading; + readonly selectedCategoryTitle = computed(() => { + const category = this.selectedCategory(); + return String(category?.['name'] ?? category?.['title'] ?? ''); + }); + readonly categoryItemCount = computed( + () => this.xtreamStore.selectItemsFromSelectedCategory().length + ); + readonly contentSortMode = this.xtreamStore.contentSortMode; + readonly playlist = computed(() => { + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return null; + } + + return { + id: String(playlist.id), + title: playlist.name ?? playlist.title ?? 'Xtream', + }; + }); + + initialize(categoryId?: string | null): void { + const savedSortMode = localStorage.getItem(SORT_STORAGE_KEY); + if ( + savedSortMode === 'date-desc' || + savedSortMode === 'date-asc' || + savedSortMode === 'name-asc' || + savedSortMode === 'name-desc' + ) { + this.xtreamStore.setContentSortMode(savedSortMode); + } + + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (playlistId) { + this.xtreamStore.loadAllPositions(playlistId); + } + + this.clearSelectedItem(); + + if (categoryId) { + this.xtreamStore.setSelectedCategory(Number(categoryId)); + return; + } + + this.xtreamStore.setSelectedCategory(null); + } + + clearSelectedItem(): void { + this.xtreamStore.setSelectedItem(null); + } + + setPage(page: number): void { + this.xtreamStore.setPage(page); + } + + setLimit(limit: number): void { + this.xtreamStore.setLimit(limit); + } + + setContentSortMode(mode: PortalCatalogSortMode): void { + this.xtreamStore.setContentSortMode(mode); + localStorage.setItem(SORT_STORAGE_KEY, mode); + } + + selectItem(item: Record): string[] | null { + const xtreamId = item['xtream_id']; + if (xtreamId === undefined || xtreamId === null) { + return null; + } + + const selectedCategoryId = this.xtreamStore.selectedCategoryId(); + if (selectedCategoryId !== null && selectedCategoryId !== undefined) { + return [String(xtreamId)]; + } + + const categoryId = item['category_id']; + if (categoryId === undefined || categoryId === null) { + return null; + } + + return [String(categoryId), String(xtreamId)]; + } + + getItemProgress( + item: Record + ): PortalCatalogItemProgress { + const isSeries = this.contentType() === 'series'; + const itemId = Number( + item['xtream_id'] ?? item['series_id'] ?? item['stream_id'] + ); + if (Number.isNaN(itemId)) { + return {}; + } + + if (isSeries) { + return { + hasSeriesProgress: this.xtreamStore.hasSeriesProgress(itemId), + }; + } + + return { + progress: this.xtreamStore.getProgressPercent(itemId, 'vod'), + isWatched: this.xtreamStore.isWatched(itemId, 'vod'), + }; + } +} + +export function provideXtreamCatalogFacade(): Provider[] { + return [ + XtreamCatalogFacadeService, + { + provide: PORTAL_CATALOG_FACADE, + useExisting: XtreamCatalogFacadeService, + }, + ]; +} diff --git a/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts b/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts new file mode 100644 index 000000000..c2cfb28aa --- /dev/null +++ b/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts @@ -0,0 +1,48 @@ +import { XtreamFeatureRouteOptions } from '@iptvnator/portal/xtream/feature'; +import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service'; + +// Temporary app-side loader map while route screens are still being migrated +// out of apps/web into the Xtream feature library. +export const xtreamFeatureRouteOptions: XtreamFeatureRouteOptions = { + catalogProviders: provideXtreamCatalogFacade(), + loadShellComponent: () => + import('./xtream-shell/xtream-shell.component').then( + (c) => c.XtreamShellComponent + ), + loadLiveStreamLayoutComponent: () => + import('./live-stream-layout/live-stream-layout.component').then( + (c) => c.LiveStreamLayoutComponent + ), + loadMainContainerComponent: () => + import('./xtream-main-container.component').then( + (c) => c.XtreamMainContainerComponent + ), + loadCategoryContentViewComponent: () => + import( + '../portal-shared/category-content-view/category-content-view.component' + ).then((c) => c.CategoryContentViewComponent), + loadVodDetailsRouteComponent: () => + import('./vod-details/vod-details-route.component').then( + (c) => c.VodDetailsRouteComponent + ), + loadSerialDetailsComponent: () => + import('./serial-details/serial-details.component').then( + (c) => c.SerialDetailsComponent + ), + loadFavoritesComponent: () => + import('./favorites/favorites.component').then( + (c) => c.FavoritesComponent + ), + loadRecentlyViewedComponent: () => + import('./recently-viewed/recently-viewed.component').then( + (c) => c.RecentlyViewedComponent + ), + loadSearchResultsComponent: () => + import('./search-results/search-results.component').then( + (c) => c.SearchResultsComponent + ), + loadRecentlyAddedComponent: () => + import('./recently-added/recently-added.component').then( + (c) => c.RecentlyAddedComponent + ), +}; diff --git a/apps/web/src/app/xtream-electron/xtream.routes.ts b/apps/web/src/app/xtream-electron/xtream.routes.ts deleted file mode 100644 index 5d93ca960..000000000 --- a/apps/web/src/app/xtream-electron/xtream.routes.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Route } from '@angular/router'; - -export const xtreamRoutes: Route[] = [ - { - path: 'xtreams/:id', - loadComponent: () => - import('./xtream-shell/xtream-shell.component').then( - (c) => c.XtreamShellComponent - ), - children: [ - { - path: '', - redirectTo: 'vod', - pathMatch: 'full', - }, - { - path: 'live', - loadComponent: () => - import( - './live-stream-layout/live-stream-layout.component' - ).then((c) => c.LiveStreamLayoutComponent), - children: [ - { - path: ':categoryId', - loadComponent: () => - import( - './live-stream-layout/live-stream-layout.component' - ).then((c) => c.LiveStreamLayoutComponent), - }, - ], - }, - { - path: 'vod', - loadComponent: () => - import('./xtream-main-container.component').then( - (c) => c.XtreamMainContainerComponent - ), - children: [ - { - path: '', - loadComponent: () => - import( - '../portal-shared/category-content-view/category-content-view.component' - ).then((c) => c.CategoryContentViewComponent), - }, - { - path: ':categoryId', - loadComponent: () => - import( - '../portal-shared/category-content-view/category-content-view.component' - ).then((c) => c.CategoryContentViewComponent), - }, - { - path: ':categoryId/:vodId', - loadComponent: () => - import( - './vod-details/vod-details-route.component' - ).then((c) => c.VodDetailsRouteComponent), - }, - ], - }, - { - path: 'series', - loadComponent: () => - import('./xtream-main-container.component').then( - (c) => c.XtreamMainContainerComponent - ), - children: [ - { - path: '', - loadComponent: () => - import( - '../portal-shared/category-content-view/category-content-view.component' - ).then((c) => c.CategoryContentViewComponent), - }, - { - path: ':categoryId', - loadComponent: () => - import( - '../portal-shared/category-content-view/category-content-view.component' - ).then((c) => c.CategoryContentViewComponent), - }, - { - path: ':categoryId/:serialId', - loadComponent: () => - import( - './serial-details/serial-details.component' - ).then((c) => c.SerialDetailsComponent), - }, - ], - }, - { - path: 'favorites', - loadComponent: () => - import('./favorites/favorites.component').then( - (c) => c.FavoritesComponent - ), - }, - { - path: 'recent', - loadComponent: () => - import('./recently-viewed/recently-viewed.component').then( - (c) => c.RecentlyViewedComponent - ), - }, - { - path: 'search', - loadComponent: () => - import('./search-results/search-results.component').then( - (c) => c.SearchResultsComponent - ), - }, - { - path: 'recently-added', - loadComponent: () => - import('./recently-added/recently-added.component').then( - (c) => c.RecentlyAddedComponent - ), - }, - { - path: 'downloads', - loadComponent: () => - import('@iptvnator/portal/downloads/feature').then( - (c) => c.DownloadsComponent - ), - }, - ], - }, -]; diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index ebe1cd1e8..7de6a6867 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/favorites-context.service'; export * from './lib/logger'; +export * from './lib/portal-catalog-facade'; export * from './lib/portal-player'; export * from './lib/portal-collection-context'; export * from './lib/portal-collection-items'; diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts new file mode 100644 index 000000000..a7f6c4957 --- /dev/null +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -0,0 +1,93 @@ +import { InjectionToken, Signal } from '@angular/core'; +import { ResolvedPortalPlayback } from 'shared-interfaces'; + +export type PortalCatalogProvider = 'xtream' | 'stalker'; + +export type PortalCatalogSortMode = + | 'date-desc' + | 'date-asc' + | 'name-asc' + | 'name-desc'; + +export interface PortalCatalogPlaylistMeta { + id: string; + title?: string; + portalUrl?: string; + macAddress?: string; + userAgent?: string; + referer?: string; + origin?: string; +} + +export interface PortalCatalogItemProgress { + progress?: number; + isWatched?: boolean; + hasSeriesProgress?: boolean; +} + +export interface PortalCatalogFacade< + TCategory = unknown, + TItem = unknown, + TSelectedItem = unknown, +> { + readonly provider: PortalCatalogProvider; + readonly pageSizeOptions: readonly number[]; + readonly contentType: Signal; + readonly limit: Signal; + readonly pageIndex: Signal; + readonly selectedCategory: Signal; + readonly paginatedContent: Signal; + readonly selectedItem: Signal; + readonly totalPages: Signal; + readonly isPaginatedContentLoading: Signal; + readonly selectedCategoryTitle: Signal; + readonly categoryItemCount: Signal; + readonly contentSortMode: Signal; + readonly playlist: Signal; + + initialize(categoryId?: string | null): void; + clearSelectedItem(): void; + setPage(page: number): void; + setLimit(limit: number): void; + setContentSortMode(mode: PortalCatalogSortMode): void; + selectItem(item: TItem): string[] | null; + getItemProgress(item: TItem): PortalCatalogItemProgress; +} + +export interface StalkerPortalCatalogFacade< + TCategory = unknown, + TItem = unknown, + TSelectedItem = unknown, +> extends PortalCatalogFacade { + readonly provider: 'stalker'; + + createLinkToPlayVod( + cmd?: string, + title?: string, + thumbnail?: string + ): Promise; + addToFavorites(item: Record, onDone?: () => void): void; + removeFromFavorites(favoriteId: string, onDone?: () => void): void; + fetchMovieFileId(itemId: string): Promise; + fetchLinkToPlay( + portalUrl: string, + macAddress: string, + cmd: string + ): Promise; + resolveVodPlayback( + cmd?: string, + title?: string, + thumbnail?: string, + startTime?: number + ): Promise; +} + +export const PORTAL_CATALOG_FACADE = new InjectionToken( + 'PORTAL_CATALOG_FACADE' +); + +export function isStalkerPortalCatalogFacade( + facade: PortalCatalogFacade +): facade is StalkerPortalCatalogFacade { + return facade.provider === 'stalker'; +} diff --git a/libs/portal/stalker/feature/project.json b/libs/portal/stalker/feature/project.json new file mode 100644 index 000000000..62a74dce7 --- /dev/null +++ b/libs/portal/stalker/feature/project.json @@ -0,0 +1,13 @@ +{ + "name": "portal-stalker-feature", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/portal/stalker/feature/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:portal", "domain:stalker", "type:feature"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/portal/stalker/feature/src/index.ts b/libs/portal/stalker/feature/src/index.ts new file mode 100644 index 000000000..9c49025e9 --- /dev/null +++ b/libs/portal/stalker/feature/src/index.ts @@ -0,0 +1 @@ +export * from './lib/stalker-feature.routes'; diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts new file mode 100644 index 000000000..16a8a85a6 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts @@ -0,0 +1,113 @@ +import { Provider } from '@angular/core'; +import { Route } from '@angular/router'; + +type ComponentLoader = NonNullable; + +export interface StalkerFeatureRouteOptions { + readonly catalogProviders?: Provider[]; + readonly loadShellComponent: ComponentLoader; + readonly loadMainContainerComponent: ComponentLoader; + readonly loadCategoryContentViewComponent: ComponentLoader; + readonly loadLiveStreamLayoutComponent: ComponentLoader; + readonly loadFavoritesComponent: ComponentLoader; + readonly loadRecentlyViewedComponent: ComponentLoader; + readonly loadSearchComponent: ComponentLoader; +} + +const loadDownloadsComponent: ComponentLoader = () => + import('@iptvnator/portal/downloads/feature').then( + (c) => c.DownloadsComponent + ); + +export function createStalkerRoutes( + options: StalkerFeatureRouteOptions +): Route[] { + return [ + { + path: 'stalker/:id', + loadComponent: options.loadShellComponent, + children: [ + { + path: '', + redirectTo: 'vod', + pathMatch: 'full', + }, + { + path: 'vod', + providers: options.catalogProviders ?? [], + loadComponent: options.loadMainContainerComponent, + children: [ + { + path: '', + data: { + api: 'stalker', + contentType: 'vod', + }, + loadComponent: + options.loadCategoryContentViewComponent, + }, + { + path: ':categoryId', + data: { + api: 'stalker', + contentType: 'vod', + }, + loadComponent: + options.loadCategoryContentViewComponent, + }, + ], + }, + { + path: '', + redirectTo: 'vod', + pathMatch: 'full', + }, + { + path: 'itv', + loadComponent: options.loadLiveStreamLayoutComponent, + }, + { + path: 'series', + providers: options.catalogProviders ?? [], + loadComponent: options.loadMainContainerComponent, + children: [ + { + path: '', + data: { + api: 'stalker', + contentType: 'series', + }, + loadComponent: + options.loadCategoryContentViewComponent, + }, + { + path: ':categoryId', + data: { + api: 'stalker', + contentType: 'series', + }, + loadComponent: + options.loadCategoryContentViewComponent, + }, + ], + }, + { + path: 'favorites', + loadComponent: options.loadFavoritesComponent, + }, + { + path: 'recent', + loadComponent: options.loadRecentlyViewedComponent, + }, + { + path: 'search', + loadComponent: options.loadSearchComponent, + }, + { + path: 'downloads', + loadComponent: loadDownloadsComponent, + }, + ], + }, + ]; +} diff --git a/libs/portal/stalker/feature/tsconfig.json b/libs/portal/stalker/feature/tsconfig.json new file mode 100644 index 000000000..f5181c8aa --- /dev/null +++ b/libs/portal/stalker/feature/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/portal/stalker/feature/tsconfig.lib.json b/libs/portal/stalker/feature/tsconfig.lib.json new file mode 100644 index 000000000..eb0b673c2 --- /dev/null +++ b/libs/portal/stalker/feature/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/portal/xtream/feature/project.json b/libs/portal/xtream/feature/project.json new file mode 100644 index 000000000..e89836816 --- /dev/null +++ b/libs/portal/xtream/feature/project.json @@ -0,0 +1,13 @@ +{ + "name": "portal-xtream-feature", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/portal/xtream/feature/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:portal", "domain:xtream", "type:feature"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts new file mode 100644 index 000000000..fed708d5a --- /dev/null +++ b/libs/portal/xtream/feature/src/index.ts @@ -0,0 +1 @@ +export * from './lib/xtream-feature.routes'; diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts new file mode 100644 index 000000000..c63001420 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts @@ -0,0 +1,115 @@ +import { Provider } from '@angular/core'; +import { Route } from '@angular/router'; + +type ComponentLoader = NonNullable; + +export interface XtreamFeatureRouteOptions { + readonly catalogProviders?: Provider[]; + readonly loadShellComponent: ComponentLoader; + readonly loadLiveStreamLayoutComponent: ComponentLoader; + readonly loadMainContainerComponent: ComponentLoader; + readonly loadCategoryContentViewComponent: ComponentLoader; + readonly loadVodDetailsRouteComponent: ComponentLoader; + readonly loadSerialDetailsComponent: ComponentLoader; + readonly loadFavoritesComponent: ComponentLoader; + readonly loadRecentlyViewedComponent: ComponentLoader; + readonly loadSearchResultsComponent: ComponentLoader; + readonly loadRecentlyAddedComponent: ComponentLoader; +} + +const loadDownloadsComponent: ComponentLoader = () => + import('@iptvnator/portal/downloads/feature').then( + (c) => c.DownloadsComponent + ); + +export function createXtreamRoutes( + options: XtreamFeatureRouteOptions +): Route[] { + return [ + { + path: 'xtreams/:id', + loadComponent: options.loadShellComponent, + children: [ + { + path: '', + redirectTo: 'vod', + pathMatch: 'full', + }, + { + path: 'live', + loadComponent: options.loadLiveStreamLayoutComponent, + children: [ + { + path: ':categoryId', + loadComponent: + options.loadLiveStreamLayoutComponent, + }, + ], + }, + { + path: 'vod', + providers: options.catalogProviders ?? [], + loadComponent: options.loadMainContainerComponent, + children: [ + { + path: '', + loadComponent: + options.loadCategoryContentViewComponent, + }, + { + path: ':categoryId', + loadComponent: + options.loadCategoryContentViewComponent, + }, + { + path: ':categoryId/:vodId', + loadComponent: + options.loadVodDetailsRouteComponent, + }, + ], + }, + { + path: 'series', + providers: options.catalogProviders ?? [], + loadComponent: options.loadMainContainerComponent, + children: [ + { + path: '', + loadComponent: + options.loadCategoryContentViewComponent, + }, + { + path: ':categoryId', + loadComponent: + options.loadCategoryContentViewComponent, + }, + { + path: ':categoryId/:serialId', + loadComponent: options.loadSerialDetailsComponent, + }, + ], + }, + { + path: 'favorites', + loadComponent: options.loadFavoritesComponent, + }, + { + path: 'recent', + loadComponent: options.loadRecentlyViewedComponent, + }, + { + path: 'search', + loadComponent: options.loadSearchResultsComponent, + }, + { + path: 'recently-added', + loadComponent: options.loadRecentlyAddedComponent, + }, + { + path: 'downloads', + loadComponent: loadDownloadsComponent, + }, + ], + }, + ]; +} diff --git a/libs/portal/xtream/feature/tsconfig.json b/libs/portal/xtream/feature/tsconfig.json new file mode 100644 index 000000000..f5181c8aa --- /dev/null +++ b/libs/portal/xtream/feature/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/portal/xtream/feature/tsconfig.lib.json b/libs/portal/xtream/feature/tsconfig.lib.json new file mode 100644 index 000000000..eb0b673c2 --- /dev/null +++ b/libs/portal/xtream/feature/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index bac3b7b4d..136ec93a4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,9 +24,15 @@ "@iptvnator/portal/shared/util": [ "libs/portal/shared/util/src/index.ts" ], + "@iptvnator/portal/stalker/feature": [ + "libs/portal/stalker/feature/src/index.ts" + ], "@iptvnator/portal/stalker/data-access": [ "libs/portal/stalker/data-access/src/index.ts" ], + "@iptvnator/portal/xtream/feature": [ + "libs/portal/xtream/feature/src/index.ts" + ], "@iptvnator/portal/xtream/data-access": [ "libs/portal/xtream/data-access/src/index.ts" ],