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"
],