Merge pull request #771 from 4gray/refactor/stalker-portals

Refactor: Stalker portal store
This commit is contained in:
4gray authored and GitHub committed 2026-02-14 16:27:33 +01:00
commit d7953d9e13
66 files changed
+4548 -1727

No files matched your search

@@ -140,6 +140,20 @@ function stopPositionPolling() {
}
}
function maskUrlForLogs(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return rawUrl;
}
}
function shouldIgnoreMpvStdoutLine(line: string): boolean {
// Suppress MPV realtime progress spam.
return /^(\(Paused\)\s*)?AV:\s/.test(line);
}
function startPositionPolling(socketPath: string, contentInfo: any) {
stopPositionPolling();
@@ -304,14 +318,16 @@ ipcMain.handle(
const mpvPath = getMpvPath();
const reuseInstance = store.get(MPV_REUSE_INSTANCE, false);
console.log('Opening MPV player with path:', mpvPath);
console.log('Reuse instance:', reuseInstance);
console.log('URL:', url);
console.log('User-Agent:', userAgent);
console.log('Referer:', referer);
console.log('Origin:', origin);
console.log('Content Info:', contentInfo);
console.log('Start Time:', startTime);
console.log('[MPV] Opening player', {
path: mpvPath,
reuseInstance,
stream: maskUrlForLogs(url),
hasUserAgent: Boolean(userAgent),
hasReferer: Boolean(referer),
hasOrigin: Boolean(origin),
hasContentInfo: Boolean(contentInfo),
startTime: startTime ?? null,
});
// If reuse is enabled and there's an existing process, try to use it
if (
@@ -410,8 +426,17 @@ ipcMain.handle(
// Capture stdout
if (proc.stdout) {
proc.stdout.on('data', (data) => {
const output = data.toString().trim();
if (output) {
const lines = data
.toString()
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const output of lines) {
if (shouldIgnoreMpvStdoutLine(output)) {
continue;
}
console.log('[MPV stdout]:', output);
// MPV sometimes outputs errors to stdout instead of stderr
@@ -12,6 +12,43 @@ export default class XtreamEvents {
}
}
function formatXtreamError(error: unknown, requestUrl: string, action?: string) {
const parsedUrl = new URL(requestUrl);
const base = {
action,
host: parsedUrl.host,
pathname: parsedUrl.pathname,
};
if (axios.isAxiosError(error)) {
return {
...base,
type: 'AxiosError',
code: error.code,
status: error.response?.status,
message: error.message,
syscall: (error as NodeJS.ErrnoException).syscall,
hostname: (error as any).hostname,
};
}
if (error && typeof error === 'object') {
const errObj = error as Record<string, unknown>;
return {
...base,
type: 'ErrorObject',
status: errObj.status,
message: errObj.message,
};
}
return {
...base,
type: 'UnknownError',
message: String(error),
};
}
/**
* Handle Xtream Codes API requests
*/
@@ -63,7 +100,10 @@ ipcMain.handle(
action: params.action,
};
} catch (error) {
console.error('Xtream request error:', error);
console.error(
'[XTREAM_REQUEST] Failed',
formatXtreamError(error, payload.url, payload.params?.action)
);
// Format error response
if (axios.isAxiosError(error)) {
@@ -33,6 +33,16 @@
')'
}}
</h2>
@if (showHeaderAction()) {
<button
mat-icon-button
[matTooltip]="headerActionTooltip()"
[disabled]="!favorites().length"
(click)="onHeaderActionClick()"
>
<mat-icon>{{ headerActionIcon() }}</mat-icon>
</button>
}
</div>
<div class="scrollable-content">
@if (favorites().length) {
@@ -44,7 +54,10 @@
[type]="item.type"
[date]="item.added_at"
[showRemoveButton]="true"
[removeTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
[removeTooltip]="
removeTooltip() ||
('CHANNELS.REMOVE_FAVORITE' | translate)
"
(cardClick)="openFavorite(item)"
(remove)="removeFromFavorites(item)"
/>
@@ -52,7 +65,7 @@
</div>
} @else {
<div class="no-items">
<mat-icon>favorite_border</mat-icon>
<mat-icon>{{ emptyIcon() }}</mat-icon>
<p>No items in the selected category</p>
</div>
}
@@ -20,6 +20,10 @@
text-transform: capitalize;
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h2 {
margin: 0;
@@ -1,6 +1,7 @@
import { Component, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component';
@@ -21,6 +22,7 @@ import { ContentCardComponent } from '../content-card/content-card.component';
PlaylistSwitcherComponent,
ResizableDirective,
TranslatePipe,
MatTooltip,
],
})
export class FavoritesLayoutComponent {
@@ -30,10 +32,16 @@ export class FavoritesLayoutComponent {
readonly playlistTitle = input<string>('Playlist');
readonly selectedCategoryId = input<string>('movie');
readonly titleTranslationString = input<string>('CHANNELS.FAVORITES');
readonly removeTooltip = input<string>('');
readonly emptyIcon = input<string>('favorite_border');
readonly showHeaderAction = input<boolean>(false);
readonly headerActionIcon = input<string>('delete_sweep');
readonly headerActionTooltip = input<string>('');
readonly categoryClicked = output<any>();
readonly removeFavorite = output<any>();
readonly openItem = output<any>();
readonly headerActionClicked = output<void>();
setCategoryId(categoryId: any) {
this.categoryClicked.emit({ category_id: categoryId });
@@ -46,4 +54,8 @@ export class FavoritesLayoutComponent {
openFavorite(item: any) {
this.openItem.emit(item);
}
onHeaderActionClick() {
this.headerActionClicked.emit();
}
}
+32
View File
@@ -0,0 +1,32 @@
import { AppConfig } from '../../../environments/environment';
export interface Logger {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
export function createLogger(scope: string): Logger {
const prefix = `[${scope}]`;
const debugEnabled = !AppConfig.production;
return {
debug: (...args: unknown[]) => {
if (debugEnabled) {
console.debug(prefix, ...args);
}
},
info: (...args: unknown[]) => {
if (debugEnabled) {
console.info(prefix, ...args);
}
},
warn: (...args: unknown[]) => {
console.warn(prefix, ...args);
},
error: (...args: unknown[]) => {
console.error(prefix, ...args);
},
};
}
@@ -1,10 +1,11 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject, input, output } from '@angular/core';
import { Component, inject, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { PlaylistsService } from 'services';
import { StalkerStore } from '../stalker.store';
import { matchesFavoriteById } from '../stalker-vod.utils';
@Component({
selector: 'app-favorites-button',
@@ -17,9 +18,7 @@ export class FavoritesButtonComponent {
private stalkerStore = inject(StalkerStore);
readonly itemId = input.required<string>();
readonly addToFavoritesClicked = output<void>();
readonly removeFromFavoritesClicked = output<void>();
readonly item = input.required<any>();
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
@@ -30,25 +29,39 @@ export class FavoritesButtonComponent {
)
),
map((favorites) =>
favorites.some(
(i) =>
(i as any).movie_id === this.itemId() ||
(i as any).id === this.itemId()
favorites.some((favorite) =>
matchesFavoriteById(favorite, this.itemId())
)
)
);
removeFromFavorites() {
this.removeFromFavoritesClicked.emit();
setTimeout(() => {
this.stalkerStore.removeFromFavorites(this.itemId(), () => {
this.favoritesChanged$.next();
}, 100);
});
}
addToFavorites() {
this.addToFavoritesClicked.emit();
setTimeout(() => {
const item = this.item();
this.stalkerStore.addToFavorites(
{
...item,
title: item.info?.name ?? item.name ?? item.o_name,
cover: item.info?.movie_image ?? item.cover,
series_id: item.id,
added_at: new Date().toISOString(),
category_id: 'series',
},
() => {
this.favoritesChanged$.next();
}
);
}
forceRefresh() {
// Exposed for callers that need manual refresh after external updates.
if (this.stalkerStore.currentPlaylist()?._id) {
this.favoritesChanged$.next();
}, 100);
}
}
}
+4
View File
@@ -1,4 +1,8 @@
export * from './stalker-favorite-item.interface';
export * from './stalker-category-item.interface';
export * from './stalker-content-item.interface';
export * from './stalker-itv-channel.interface';
export * from './stalker-season.interface';
export * from './stalker-serial-details.interface';
export * from './stalker-vod-details.interface';
export * from './stalker-vod-series.interface';
@@ -0,0 +1,4 @@
export interface StalkerCategoryItem {
category_id: string;
category_name: string;
}
@@ -0,0 +1,9 @@
import { StalkerVodSource } from './stalker-favorite-item.interface';
/**
* Generic content item returned by Stalker category content endpoints.
* Used for VOD, series, and ITV category lists.
*/
export interface StalkerContentItem extends StalkerVodSource {
cover?: string;
}
@@ -1,7 +1,67 @@
export interface StalkerFavoriteItem {
id?: string;
stream_id?: string;
movie_id?: string;
name: string;
details?: any;
import {
StalkerVodDetails,
StalkerVodInfo,
} from 'shared-interfaces';
export type StalkerSeriesFlag = true | 1 | '1';
/**
* Loose source shape coming from Stalker responses / favorites payloads.
* Keep optional to model heterogeneous portal responses.
*/
export interface StalkerVodSource {
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
cmd?: string;
series?: unknown[];
has_files?: number;
is_series?: boolean | number | string | null | undefined;
video_id?: string;
category_id?: string;
title?: string;
cover?: string;
logo?: string;
screenshot_uri?: string;
name?: string;
o_name?: string;
description?: string;
actors?: string;
director?: string;
releasedate?: string;
year?: string;
genre?: string;
genres_str?: string;
rating_imdb?: string | number;
rating_kinopoisk?: string | number;
info?: Partial<StalkerVodInfo> | null;
}
/**
* Normalized VOD item used by Stalker detail/favorites flows.
*/
export interface StalkerSelectedVodItem extends StalkerVodDetails {
series?: unknown[];
has_files?: number;
is_series?: true;
video_id?: string;
category_id?: string;
}
/**
* Favorite item persisted for Stalker portal.
*/
export interface StalkerFavoriteItem {
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
category_id?: string;
cmd?: string;
name?: string;
o_name?: string;
logo?: string;
details?: StalkerSelectedVodItem;
[key: string]: unknown;
}
@@ -0,0 +1,13 @@
import { StalkerVodSource } from './stalker-favorite-item.interface';
/**
* ITV/live channel shape used by Stalker live TV flow.
* Stalker channel payloads are heterogeneous, so this extends the shared source shape.
*/
export interface StalkerItvChannel extends StalkerVodSource {
id: string | number;
cmd: string;
name?: string;
o_name?: string;
logo?: string;
}
@@ -0,0 +1,24 @@
/**
* Season item returned by Stalker VOD-series (Ministra is_series=1) endpoints.
*/
export interface StalkerVodSeriesSeason {
id: string;
video_id: string;
season_number?: string;
name?: string;
is_season?: boolean;
}
/**
* Episode item returned by Stalker VOD-series season endpoints.
*/
export interface StalkerVodSeriesEpisode {
id: string;
series_number?: number;
episode_num?: number;
name?: string;
cover?: string;
description?: string;
duration?: number | string;
is_episode?: boolean;
}
@@ -16,6 +16,7 @@
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
@@ -34,8 +35,14 @@
[playlistTitle]="currentPlaylist()?.title || 'Portal'"
[selectedCategoryId]="selectedCategoryId()"
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
[removeTooltip]="'Remove from recent'"
[emptyIcon]="'history'"
[showHeaderAction]="true"
[headerActionIcon]="'delete_sweep'"
[headerActionTooltip]="'Clear all recently viewed'"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFavorite)="removeFromRecentlyViewed($event)"
(openItem)="openItem($event)"
(headerActionClicked)="clearAllRecentlyViewed()"
/>
}
@@ -1,16 +1,28 @@
import { Component, computed, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
VodDetailsItem,
StalkerVodDetails,
createStalkerVodItem,
} from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../stalker.store';
import { createLogger } from '../../shared/utils/logger';
import {
StalkerSelectedVodItem,
StalkerVodSource,
} from '../models';
import {
createPortalCollectionResource,
createPortalFavoritesResource,
toggleStalkerVodFavorite,
clearStalkerDetailViewState,
createStalkerDetailViewState,
createRefreshTrigger,
normalizeStalkerVodDetailsItem,
isSelectedStalkerVodFavorite,
} from '../stalker-vod.utils';
@Component({
selector: 'app-recently-viewed',
@@ -36,28 +48,36 @@ import { StalkerStore } from '../stalker.store';
],
})
export class RecentlyViewedComponent {
private static isCategoryType(
value: string
): value is 'vod' | 'series' | 'itv' {
return value === 'vod' || value === 'series' || value === 'itv';
}
private readonly playlistService = inject(PlaylistsService);
private readonly refreshTimestamp = signal<number>(Date.now());
private readonly collectionRefresh = createRefreshTrigger();
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerRecentlyViewed');
itemDetails: any = null;
itemDetails: (StalkerVodSource & { category_id?: string }) | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly allFavorites = rxResource({
params: () => {
const portalId = this.stalkerStore.currentPlaylist()?._id;
if (!portalId) return undefined;
return {
portalId,
refreshTimestamp: this.refreshTimestamp(),
};
},
stream: ({ params }) =>
this.playlistService.getPortalRecentlyViewed(params.portalId),
});
readonly allFavorites = createPortalCollectionResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.collectionRefresh.refreshVersion(),
(playlistService, portalId) =>
playlistService.getPortalRecentlyViewed(portalId)
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() => [
{
@@ -119,18 +139,43 @@ export class RecentlyViewedComponent {
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
removeFromRecentlyViewed(item: any) {
this.stalkerStore.removeFromRecentlyViewed(item.id);
setTimeout(() => {
this.refreshTimestamp.set(Date.now());
}, 100);
constructor() {
effect(() => {
this.portalFavorites.value();
this.syncSelectedVodFavorite();
});
}
setCategoryId(categoryId: any) {
removeFromRecentlyViewed(item: Pick<StalkerVodSource, 'id'>) {
const id = String(item.id ?? '').trim();
if (!id) return;
this.stalkerStore.removeFromRecentlyViewed(id, () => {
this.collectionRefresh.refresh();
});
}
clearAllRecentlyViewed() {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) return;
this.playlistService.clearPortalRecentlyViewed(playlistId).subscribe({
next: () => this.collectionRefresh.refresh(),
error: (error) =>
this.logger.error(
'Failed to clear recently viewed items',
error
),
});
}
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
}
openItem(item: any) {
openItem(item: StalkerVodSource & { category_id: string }) {
if (!RecentlyViewedComponent.isCategoryType(item.category_id)) {
return;
}
this.stalkerStore.setSelectedContentType(item.category_id);
switch (item.category_id) {
case 'itv':
@@ -139,24 +184,17 @@ export class RecentlyViewedComponent {
case 'vod':
// Normalize the item to ensure is_series flag is properly set
const normalizedItem = this.normalizeRecentItem(item);
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem);
// Check if this VOD item is actually a series (Ministra is_series=1)
const isVodSeries =
normalizedItem.is_series === true ||
normalizedItem.series?.length > 0;
if (isVodSeries) {
// VOD series - will be rendered as series view
this.vodDetailsItem = null;
} else {
// Regular VOD - create VodDetailsItem for the component
this.vodDetailsItem = createStalkerVodItem(
normalizedItem as StalkerVodDetails,
this.currentPlaylist()?._id ?? ''
);
}
const detailViewState = createStalkerDetailViewState(
normalizedItem,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = {
...(detailViewState.itemDetails ?? normalizedItem),
category_id: 'vod',
};
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.stalkerStore.setSelectedItem(detailViewState.itemDetails);
this.syncSelectedVodFavorite();
break;
case 'series':
this.itemDetails = item;
@@ -180,25 +218,23 @@ export class RecentlyViewedComponent {
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
if (event.item.type === 'stalker') {
if (event.isFavorite) {
this.addToFavorites({
...event.item.data,
category_id: 'vod',
title: event.item.data.info?.name,
cover: event.item.data.info?.movie_image,
added_at: new Date().toISOString(),
});
} else {
this.removeFromFavorites(event.item.data.id);
}
}
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.itemDetails = null;
this.vodDetailsItem = null;
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
async createLinkToPlayVodItv(
@@ -206,44 +242,36 @@ export class RecentlyViewedComponent {
title?: string,
thumbnail?: string
) {
console.debug('Create link to play VOD/ITV', cmd, title, thumbnail);
this.logger.debug('Create link to play VOD/ITV', {
cmd,
title,
thumbnail,
});
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: any) {
this.stalkerStore.addToFavorites(item);
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, onDone);
}
removeFromFavorites(favoriteId: string) {
this.stalkerStore.removeFromFavorites(favoriteId);
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
/**
* Normalize recent item to ensure is_series flag is properly set
* and all required fields are present for the series view
*/
private normalizeRecentItem(item: any): any {
// Normalize is_series to boolean true (can be 1, "1", or true)
const rawIsSeries = item.is_series;
const normalizedIsSeries =
rawIsSeries === true ||
rawIsSeries === 1 ||
rawIsSeries === '1';
private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem {
return normalizeStalkerVodDetailsItem(item);
}
// Ensure info object exists
const info = item.info || {
name: item.o_name || item.name || item.title || 'Unknown',
o_name: item.o_name,
movie_image: item.cover || item.screenshot_uri || item.logo,
description: item.description,
};
return {
...item,
info: info,
is_series: normalizedIsSeries ? true : undefined,
// Preserve video_id for season fetching
video_id: item.video_id,
};
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem,
this.portalFavorites.value() ?? []
)
);
}
}
@@ -5,22 +5,18 @@
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails.details"
(backClicked)="itemDetails = null; vodDetailsItem = null"
(backClicked)="onSeriesBack()"
/>
} @else if (
itemDetails.details?.is_series === true ||
itemDetails.details?.is_series === 1 ||
itemDetails.details?.is_series === '1'
) {
} @else if (itemDetails.details?.is_series === true) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view
(backClicked)="itemDetails = null; vodDetailsItem = null"
(backClicked)="onSeriesBack()"
/>
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
[isFavorite]="true"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
@@ -28,7 +24,7 @@
}
}
@case ('series') {
<app-stalker-series-view (backClicked)="itemDetails = null" />
<app-stalker-series-view (backClicked)="onSeriesBack()" />
}
}
} @else {
@@ -1,16 +1,28 @@
import { Component, computed, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
VodDetailsItem,
StalkerVodDetails,
createStalkerVodItem,
} from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../stalker.store';
import { createLogger } from '../../shared/utils/logger';
import {
StalkerFavoriteItem,
} from '../models';
import {
clearStalkerDetailViewState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
normalizeStalkerEntityId,
NormalizedStalkerFavoriteItem,
normalizeStalkerFavoriteItem,
isSelectedStalkerVodFavorite,
toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
@Component({
selector: 'app-stalker-favorites',
@@ -37,27 +49,22 @@ import { StalkerStore } from '../stalker.store';
})
export class StalkerFavoritesComponent {
private readonly playlistService = inject(PlaylistsService);
private readonly refreshTimestamp = signal<number>(Date.now());
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerFavorites');
itemDetails: any = null;
itemDetails: NormalizedStalkerFavoriteItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly allFavorites = rxResource({
params: () => {
const portalId = this.stalkerStore.currentPlaylist()?._id;
if (!portalId) return undefined;
return {
portalId,
refreshTimestamp: this.refreshTimestamp(),
};
},
stream: ({ params }) =>
this.playlistService.getPortalFavorites(params.portalId),
});
readonly allFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() => [
{
@@ -119,20 +126,39 @@ export class StalkerFavoritesComponent {
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
removeFromFavorites(item: any) {
this.stalkerStore.removeFromFavorites(item.id);
setTimeout(() => {
this.refreshTimestamp.set(Date.now());
}, 100);
constructor() {
effect(() => {
this.allFavorites.value();
this.syncSelectedVodFavorite();
});
}
setCategoryId(categoryId: any) {
removeFromFavorites(item: Pick<StalkerFavoriteItem, 'id'>, onDone?: () => void) {
this.stalkerStore.removeFromFavorites(
normalizeStalkerEntityId(item.id),
() => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
}
);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
});
}
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
}
openItem(item: any) {
console.debug('Open item', item);
const normalizedItem = this.normalizeFavoriteItem(item);
openItem(item: StalkerFavoriteItem) {
this.logger.debug('Open item', item);
const normalizedItem = normalizeStalkerFavoriteItem(item);
switch (item.category_id) {
case 'itv':
@@ -144,25 +170,16 @@ export class StalkerFavoritesComponent {
this.stalkerStore.setSelectedItem(normalizedItem.details);
this.stalkerStore.setSelectedContentType('vod');
// Check if this VOD item is actually a series (Ministra is_series=1)
const isVodSeries =
normalizedItem.details?.is_series === true ||
normalizedItem.details?.is_series === 1 ||
normalizedItem.details?.is_series === '1' ||
item.is_series === true ||
item.is_series === 1 ||
item.is_series === '1';
if (isVodSeries || normalizedItem.details?.series?.length > 0) {
// VOD series - will be rendered as series view
this.vodDetailsItem = null;
} else {
// Regular VOD - create VodDetailsItem for the component
this.vodDetailsItem = createStalkerVodItem(
normalizedItem.details as StalkerVodDetails,
this.currentPlaylist()?._id ?? ''
);
}
const detailViewState = createStalkerDetailViewState(
normalizedItem.details,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = {
...normalizedItem,
details: detailViewState.itemDetails,
};
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
break;
case 'series':
this.itemDetails = normalizedItem;
@@ -187,61 +204,43 @@ export class StalkerFavoritesComponent {
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
if (!event.isFavorite && event.item.type === 'stalker') {
this.removeFromFavorites({ id: event.item.data.id });
}
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites({ id: favoriteId }, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.itemDetails = null;
this.vodDetailsItem = null;
this.resetDetailsView(true);
}
private normalizeFavoriteItem(item: any): any {
if (item.details && item.details.info) {
return item;
onSeriesBack(): void {
this.resetDetailsView(true);
}
private resetDetailsView(refreshList: boolean): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails = null;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
if (refreshList) {
this.favoritesRefresh.refresh();
}
}
const source = item.details || item;
const info = source.info || {
name: item.o_name || item.name || item.title || 'Unknown',
o_name: item.o_name,
movie_image: item.cover || item.screenshot_uri || item.logo,
description: item.description,
releasedate: item.releasedate || item.year,
genre: item.genre || item.genres_str,
duration: item.duration,
rating_imdb: item.rating_imdb || item.rating,
country: item.country,
actors: item.actors,
director: item.director,
backdrop_path:
item.backdrop_path ||
[item.cover, item.screenshot_uri].filter(Boolean),
};
// Normalize is_series to boolean true (can be 1, "1", or true)
const rawIsSeries =
item.is_series || source.is_series;
const normalizedIsSeries =
rawIsSeries === true ||
rawIsSeries === 1 ||
rawIsSeries === '1';
return {
...item,
details: {
...source,
info: info,
cmd: item.cmd || source.cmd,
id: item.stream_id || item.id || source.id,
series: item.series || source.series,
is_series: normalizedIsSeries ? true : undefined,
// Preserve video_id for season fetching
video_id: item.video_id || source.video_id,
},
};
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem,
this.allFavorites.value() ?? []
)
);
}
async createLinkToPlayVodItv(
@@ -249,7 +248,11 @@ export class StalkerFavoritesComponent {
title?: string,
thumbnail?: string
) {
console.debug('Create link to play VOD/ITV', cmd, title, thumbnail);
this.logger.debug('Create link to play VOD/ITV', {
cmd,
title,
thumbnail,
});
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
}
@@ -34,6 +34,15 @@ import { CategoryViewComponent } from '../../xtream-tauri/category-view/category
import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component';
import { StalkerStore } from '../stalker.store';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { createLogger } from '../../shared/utils/logger';
import {
StalkerCategoryItem,
StalkerFavoriteItem,
StalkerItvChannel,
} from '../models';
import {
normalizeStalkerEntityId,
} from '../stalker-vod.utils';
@Component({
selector: 'app-stalker-live-stream-layout',
@@ -69,6 +78,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerLiveStream');
/** Categories */
readonly categories = this.stalkerStore.getCategoryResource;
@@ -97,15 +107,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private epgChannelId: number | string | null = null;
/** Channel list EPG preview */
readonly currentPrograms = new Map<number, string>();
readonly currentProgramsProgress = new Map<number, number>();
readonly programTimings = new Map<number, { start: number; end: number }>();
private readonly requestedEpgChannels = new Set<number>();
readonly currentPrograms = new Map<string | number, string>();
readonly currentProgramsProgress = new Map<string | number, number>();
readonly programTimings = new Map<string | number, { start: number; end: number }>();
private readonly requestedEpgChannels = new Set<string | number>();
private readonly cdr = inject(ChangeDetectorRef);
private readonly ngZone = inject(NgZone);
/** Favorites */
readonly favorites = new Map<number, boolean>();
readonly favorites = new Map<string | number, boolean>();
/** Scroll */
readonly scrollContainer = viewChild<ElementRef>('scrollContainer');
@@ -120,8 +130,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
.pipe(takeUntilDestroyed())
.subscribe((favs) => {
favs.forEach((fav: any) => {
this.favorites.set(fav.id, true);
favs.forEach((fav: StalkerFavoriteItem) => {
if (fav.id !== undefined) {
this.favorites.set(normalizeStalkerEntityId(fav.id), true);
}
});
});
@@ -228,7 +240,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
}
selectCategory(item: { category_name: string; category_id: string }) {
selectCategory(item: StalkerCategoryItem) {
this.stalkerStore.setSelectedCategory(item.category_id || '*');
this.stalkerStore.setPage(0);
}
@@ -239,7 +251,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.stalkerStore.setSelectedCategory(null);
}
async playChannel(item: any) {
async playChannel(item: StalkerItvChannel) {
this.stalkerStore.setSelectedItem(item);
const isEmbeddedPlayer =
@@ -279,7 +291,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
title: item.o_name || item.name,
});
} catch (error) {
console.error('[StalkerLiveStream] Playback failed:', error);
this.logger.error('Playback failed', error);
const errorMessage =
error?.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
@@ -288,10 +300,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
}
toggleFavorite(item: any) {
if (this.favorites.has(item.id)) {
this.stalkerStore.removeFromFavorites(item.id);
this.favorites.delete(item.id);
toggleFavorite(item: StalkerItvChannel) {
const itemId = normalizeStalkerEntityId(item.id);
if (this.favorites.has(itemId)) {
this.stalkerStore.removeFromFavorites(itemId);
this.favorites.delete(itemId);
} else {
this.stalkerStore.addToFavorites({
...item,
@@ -300,7 +313,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
cover: item.logo,
added_at: new Date().toISOString(),
});
this.favorites.set(item.id, true);
this.favorites.set(itemId, true);
}
}
@@ -349,15 +362,17 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
}
private async loadEpgPreviewsForChannels(channels: any[]) {
private async loadEpgPreviewsForChannels(channels: StalkerItvChannel[]) {
const newChannels = channels.filter(
(ch) => ch.id && !this.requestedEpgChannels.has(ch.id)
(ch) =>
ch.id &&
!this.requestedEpgChannels.has(normalizeStalkerEntityId(ch.id))
);
if (newChannels.length === 0) return;
// Mark all as requested immediately to avoid duplicates
for (const ch of newChannels) {
this.requestedEpgChannels.add(ch.id);
this.requestedEpgChannels.add(normalizeStalkerEntityId(ch.id));
}
// Process in batches of 3 with a small delay between batches
@@ -376,7 +391,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
}
private async loadSingleEpgPreview(channelId: number) {
private async loadSingleEpgPreview(channelId: number | string) {
try {
const items = await this.stalkerStore.fetchChannelEpg(
channelId,
@@ -384,7 +399,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
);
if (items.length > 0) {
const program = items[0];
this.currentPrograms.set(channelId, program.title);
const id = normalizeStalkerEntityId(channelId);
this.currentPrograms.set(id, program.title);
const now = Date.now() / 1000;
const start = parseInt(program.start_timestamp, 10);
@@ -393,11 +409,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
if (start && end && now >= start && now <= end) {
const progress =
((now - start) / (end - start)) * 100;
this.currentProgramsProgress.set(
channelId,
progress
);
this.programTimings.set(channelId, {
this.currentProgramsProgress.set(id, progress);
this.programTimings.set(id, {
start: start * 1000,
end: end * 1000,
});
@@ -16,6 +16,7 @@
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
@@ -1,18 +1,16 @@
import { Component, inject, resource, signal } from '@angular/core';
import { Component, effect, inject, resource, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { selectPlaylistById } from 'm3u-state';
import { DataService, StalkerSessionService } from 'services';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
StalkerVodDetails,
VodDetailsItem,
createStalkerVodItem,
} from 'shared-interfaces';
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
@@ -20,6 +18,21 @@ import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.compon
import { StalkerContentTypes } from '../stalker-content-types';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../stalker.store';
import { createLogger } from '../../shared/utils/logger';
import {
StalkerSelectedVodItem,
StalkerVodSource,
} from '../models';
import {
buildStalkerSelectedVodItem,
clearStalkerDetailViewState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
isSelectedStalkerVodFavorite,
isStalkerSeriesFlag,
toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
interface StalkerFilter {
key: string;
@@ -44,9 +57,11 @@ interface StalkerFilter {
export class StalkerSearchComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly playlistService = inject(PlaylistsService);
private readonly stalkerStore = inject(StalkerStore);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly store = inject(Store);
private readonly logger = createLogger('StalkerSearch');
readonly filters = signal({
series: false,
@@ -73,10 +88,17 @@ export class StalkerSearchComponent {
);
readonly selectedFilterType = signal('vod');
private readonly favoritesRefresh = createRefreshTrigger();
itemDetails: any = null;
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly searchResultsResource = resource({
params: () => ({
contentType: this.selectedFilterType(),
@@ -101,7 +123,7 @@ export class StalkerSearchComponent {
token = result.token ?? undefined;
serialNumber = (playlist as Playlist).stalkerSerialNumber;
} catch (error) {
console.error('Failed to get stalker token:', error);
this.logger.error('Failed to get stalker token', error);
}
}
@@ -123,7 +145,7 @@ export class StalkerSearchComponent {
);
if (response) {
const items = response.js?.data || [];
return items.map((item: any) =>
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
);
} else {
@@ -134,6 +156,16 @@ export class StalkerSearchComponent {
},
});
readonly isSelectedVodFavorite = signal<boolean>(false);
constructor() {
effect(() => {
// Re-evaluate favorite state whenever favorites resource changes.
this.portalFavorites.value();
this.syncSelectedVodFavorite();
});
}
/** Check if showing item details */
get showingDetails(): boolean {
return this.itemDetails !== null;
@@ -166,33 +198,14 @@ export class StalkerSearchComponent {
}
}
selectItem(item: any) {
selectItem(item: StalkerVodSource) {
const hasEmbeddedSeries = item.series?.length > 0;
const needsSeriesFetch =
this.selectedFilterType() === 'vod' &&
!hasEmbeddedSeries &&
(item.is_series === '1' || item.is_series === 1);
isStalkerSeriesFlag(item.is_series);
this.itemDetails = {
id: item.id,
cmd: item.cmd,
series: item.series,
has_files: item.has_files,
is_series: needsSeriesFetch ? true : undefined,
video_id: item.video_id,
info: {
movie_image: item.screenshot_uri,
description: item.description,
name: item.o_name || item.name,
o_name: item.o_name,
director: item.director,
releasedate: item.year,
genre: item.genres_str,
actors: item.actors,
rating_imdb: item.rating_imdb,
rating_kinopoisk: item.rating_kinopoisk,
},
};
this.itemDetails = buildStalkerSelectedVodItem(item, needsSeriesFetch);
this.stalkerStore.setSelectedItem(this.itemDetails);
@@ -200,13 +213,17 @@ export class StalkerSearchComponent {
case 'vod':
this.stalkerStore.setSelectedContentType('vod');
if (!hasEmbeddedSeries && !needsSeriesFetch) {
const playlist = this.currentPlaylist();
this.vodDetailsItem = createStalkerVodItem(
this.itemDetails as StalkerVodDetails,
playlist?._id ?? ''
const detailViewState = createStalkerDetailViewState(
this.itemDetails,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = detailViewState.itemDetails;
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
} else {
this.vodDetailsItem = null;
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
break;
case 'series':
@@ -231,35 +248,46 @@ export class StalkerSearchComponent {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
if (event.item.type === 'stalker') {
if (event.isFavorite) {
this.addToFavorites({
...event.item.data,
category_id: 'vod',
title: event.item.data.info?.name,
cover: event.item.data.info?.movie_image,
added_at: new Date().toISOString(),
});
} else {
this.removeFromFavorites(event.item.data.id);
}
}
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
onVodBack(): void {
this.itemDetails = null;
this.vodDetailsItem = null;
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
addToFavorites(item: any) {
this.stalkerStore.addToFavorites(item);
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
removeFromFavorites(favoriteId: string) {
this.stalkerStore.removeFromFavorites(favoriteId);
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, onDone);
}
private processItemUrls(item: any, portalUrl: string): any {
private syncSelectedVodFavorite(): void {
const item = this.vodDetailsItem;
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
item,
this.portalFavorites.value() ?? []
)
);
}
private processItemUrls(
item: StalkerVodSource,
portalUrl: string
): StalkerVodSource {
const processed = { ...item };
if (processed.screenshot_uri) {
@@ -7,14 +7,14 @@
>
<!-- Tags: Year, Genre, Rating -->
<ng-container hero-tags>
@if (serial.info.releasedate || serial.info.year) {
@if (serial.info.releasedate) {
<span class="details__tag">{{
serial.info.releasedate || serial.info.year
serial.info.releasedate
}}</span>
}
@if (serial.info.genre || serial.info.genres_str) {
@if (serial.info.genre) {
<span class="details__tag">{{
serial.info.genre || serial.info.genres_str
serial.info.genre
}}</span>
}
@if (serial.info.rating_imdb) {
@@ -53,8 +53,7 @@
<ng-container hero-actions>
<app-favorites-button
[itemId]="serial.id"
(addToFavoritesClicked)="addToFavorites(serial)"
(removeFromFavoritesClicked)="removeFromFavorites(serial.id)"
[item]="serial"
/>
</ng-container>
@@ -62,7 +61,7 @@
@if (mappedSeasons()) {
<app-season-container
[seasons]="mappedSeasons()"
[seriesId]="serial.id"
[seriesId]="toSeriesId(serial.id)"
[seriesTitle]="serial.info?.name"
[isLoading]="
isVodSeriesSeasonsLoading() ||
@@ -76,4 +75,3 @@
}
</app-content-hero>
}
@@ -14,29 +14,20 @@ import { StalkerStore } from '../stalker.store';
import { SeasonContainerComponent } from '../../xtream-tauri/season-container/season-container.component';
import { XtreamSerieEpisode } from 'shared-interfaces';
import { XtreamStore } from '../../xtream-tauri/stores/xtream.store';
/**
* VOD series season with episodes loaded dynamically
*/
interface VodSeriesSeason {
id: string;
video_id: string;
name: string;
season_number: string;
episodes?: any[];
isLoading?: boolean;
isExpanded?: boolean;
}
/**
* Common interface for regular series seasons
*/
interface StalkerSeriesSeason {
id: string;
name: string;
cmd?: string;
series?: any[];
}
import { createLogger } from '../../shared/utils/logger';
import {
getVodSeriesSeasonKey,
isVodSeriesItem,
mapRegularSeriesEpisodes,
mapRegularSeriesSeasons,
mapVodSeriesEpisodes,
mapVodSeriesSeasonsToVm,
StalkerMappedEpisode,
StalkerSeriesSeasonVm,
VodSeriesSeasonVm,
} from '../stalker-series.adapters';
import { StalkerSelectedVodItem, StalkerVodSource } from '../models';
import { normalizeStalkerVodDetailsItem } from '../stalker-vod.utils';
/**
* Component for displaying series/episodes for Stalker portal content.
@@ -60,30 +51,29 @@ export class StalkerSeriesViewComponent {
readonly stalkerStore = inject(StalkerStore);
private readonly xtreamStore = inject(XtreamStore);
readonly backClicked = output<void>();
private readonly logger = createLogger('StalkerSeriesView');
/**
* Optional input for VOD items with embedded series array (vclub mode)
* When provided, uses this instead of fetching seasons from API
*/
readonly vodWithSeries = input<any>(null);
readonly vodWithSeries = input<StalkerVodSource | null>(null);
readonly selectedItem = this.stalkerStore.selectedItem;
/**
* Track VOD series seasons with their loaded episodes
*/
readonly vodSeriesSeasons = signal<VodSeriesSeason[]>([]);
readonly vodSeriesSeasons = signal<VodSeriesSeasonVm[]>([]);
/**
* Indicates if this is a VOD series item (Ministra is_series=1)
* Note: is_series can be true, 1, or "1" depending on the source
*/
readonly isVodSeries = computed(() => {
const item = this.displayItem();
return (
item?.is_series === true ||
item?.is_series === 1 ||
item?.is_series === '1'
this.stalkerStore.selectedContentType() === 'vod' &&
isVodSeriesItem(this.displayItem())
);
});
@@ -101,30 +91,10 @@ export class StalkerSeriesViewComponent {
constructor() {
// Effect to load VOD series seasons when a VOD series item is selected
effect(() => {
const item = this.displayItem();
const isSeries =
item?.is_series === true ||
item?.is_series === 1 ||
item?.is_series === '1';
if (isSeries) {
if (this.isVodSeries()) {
// Get seasons from the resource
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
if (seasons && seasons.length > 0) {
// Map seasons to our VodSeriesSeason structure
const mappedSeasons: VodSeriesSeason[] = seasons.map(
(season: any) => ({
id: season.id,
video_id: season.video_id,
name:
season.name || `Season ${season.season_number}`,
season_number: season.season_number,
episodes: [],
isLoading: false,
isExpanded: false,
})
);
this.vodSeriesSeasons.set(mappedSeasons);
}
this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons));
} else {
this.vodSeriesSeasons.set([]);
}
@@ -136,12 +106,21 @@ export class StalkerSeriesViewComponent {
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const seriesId = Number(item.id);
console.log(
`[StalkerSeriesView] Loading positions for series: id=${item.id}, seriesId=${seriesId}, is_series=${item.is_series}`
const normalizedSeriesId = this.toSeriesId(item.id);
this.logger.debug(
'Loading positions for series',
{
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
}
);
if (!isNaN(seriesId)) {
if (!isNaN(normalizedSeriesId)) {
// Load playback positions from the XtreamStore (works for any playlist type)
this.xtreamStore.loadSeriesPositions(playlist._id, seriesId);
this.xtreamStore.loadSeriesPositions(
playlist._id,
normalizedSeriesId
);
}
}
});
@@ -151,133 +130,38 @@ export class StalkerSeriesViewComponent {
* For VOD with embedded series, we create a single "season" with the episodes
* For regular series, we use the API-fetched seasons
*/
readonly regularSeasons = computed<StalkerSeriesSeason[]>(() => {
const vodItem = this.vodWithSeries();
if (vodItem?.series?.length > 0) {
// VOD with embedded series - create a pseudo-season structure
return [
{
id: vodItem.id,
name: vodItem.info?.name || 'Episodes',
cmd: vodItem.cmd,
series: vodItem.series,
},
];
}
// Regular series - use API-fetched seasons
return this.stalkerStore.getSerialSeasonsResource() as unknown as StalkerSeriesSeason[];
});
readonly regularSeasons = computed<StalkerSeriesSeasonVm[]>(() =>
mapRegularSeriesSeasons(
this.vodWithSeries(),
this.stalkerStore.getSerialSeasonsResource()
)
);
/**
* Get the item to display details for (either vodWithSeries or selectedItem from store)
*/
readonly displayItem = computed(() => {
return this.vodWithSeries() || this.selectedItem();
readonly displayItem = computed<StalkerSelectedVodItem | null>(() => {
const item = this.vodWithSeries() || this.selectedItem();
return item ? normalizeStalkerVodDetailsItem(item) : null;
});
/**
* Simple hash function to generate consistent numeric IDs from strings
*/
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Generate a unique episode ID for Stalker episodes.
* For VOD series: ALWAYS hash with season + episode number because
* the API returns the same ID (series/movie ID) for all episodes.
* For regular series: hash the cmd + episode number
*/
private generateEpisodeId(
episodeId: any,
episodeNum: number,
seasonKey: string,
isVodSeries: boolean
): number {
if (isVodSeries) {
// For VOD series, ALWAYS create unique ID from season + episode number
// The API-provided ep.id is the same for all episodes (it's the series ID)
return this.hashString(`vod_${seasonKey}_${episodeNum}`);
} else {
// For regular series, create unique ID from cmd + episode number
const cmd = String(episodeId || '');
return this.hashString(`${cmd}_ep_${episodeNum}`);
}
}
/**
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
*/
readonly mappedSeasons = computed<Record<string, XtreamSerieEpisode[]>>(
() => {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
if (this.isVodSeries()) {
// Map VOD Series (async loaded)
this.vodSeriesSeasons().forEach((season) => {
const seasonKey =
season.season_number || season.name || season.id;
// Map existing episodes or empty array
mapped[seasonKey] = (season.episodes || []).map(
(ep) => {
const episodeNum = ep.series_number || ep.episode_num || 0;
return {
episode_num: episodeNum,
title:
ep.name ||
`Episode ${episodeNum}`,
container_extension: 'mpg',
info: {
movie_image:
ep.cover ||
this.displayItem().info.movie_image,
plot: ep.description || '',
duration: ep.duration
? `${ep.duration} min`
: '',
},
// Use unique ID for watched status tracking
id: this.generateEpisodeId(ep.id, episodeNum, seasonKey, true),
// Store original API id for playback
originalId: ep.id,
custom_sid: 'vod-series', // marker
} as unknown as XtreamSerieEpisode;
}
);
});
} else {
// Map Regular Series (just numbers)
(this.regularSeasons() || []).forEach((season, index) => {
const seasonKey = (index + 1).toString();
mapped[seasonKey] = (season.series || []).map(
(epNum) =>
({
episode_num: epNum,
title: `Episode ${epNum}`,
container_extension: '',
info: {
movie_image:
this.displayItem().info.movie_image,
},
custom_sid: 'regular-series', // marker
// Use unique ID for watched status tracking
id: this.generateEpisodeId(season.cmd, epNum, seasonKey, false),
// Store cmd for playback
originalCmd: season.cmd,
}) as unknown as XtreamSerieEpisode
);
});
return mapVodSeriesEpisodes(
this.vodSeriesSeasons(),
this.displayItem()?.info?.movie_image
);
}
return mapped;
return mapRegularSeriesEpisodes(
this.regularSeasons(),
this.displayItem()?.info?.movie_image
);
}
);
@@ -290,10 +174,10 @@ export class StalkerSeriesViewComponent {
const seasons = this.vodSeriesSeasons();
const season = seasons.find(
(s) => (s.season_number || s.name || s.id) == seasonKey
(s) => getVodSeriesSeasonKey(s) === seasonKey
);
if (season && (!season.episodes || season.episodes.length === 0)) {
if (season && season.episodes.length === 0) {
this.loadEpisodesForSeason(season);
}
}
@@ -301,7 +185,7 @@ export class StalkerSeriesViewComponent {
/**
* Loads episodes for a specific VOD season
*/
async loadEpisodesForSeason(season: VodSeriesSeason) {
async loadEpisodesForSeason(season: VodSeriesSeasonVm) {
// Set loading state in local signal
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
@@ -329,10 +213,7 @@ export class StalkerSeriesViewComponent {
this.vodSeriesSeasons.set(newSeasons);
}
} catch (error) {
console.error(
'[StalkerSeriesViewComponent] Failed to load episodes:',
error
);
this.logger.error('Failed to load episodes', error);
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
if (newIndex !== -1) {
@@ -351,20 +232,22 @@ export class StalkerSeriesViewComponent {
isCurrentSeasonLoading(seasonKey: string): boolean {
if (!this.isVodSeries()) return false;
const season = this.vodSeriesSeasons().find(
(s) => (s.season_number || s.name || s.id) == seasonKey
(s) => getVodSeriesSeasonKey(s) === seasonKey
);
return season?.isLoading || false;
return season?.isLoading ?? false;
}
/**
* Handles episode click from the container
*/
onEpisodeClicked(episode: XtreamSerieEpisode) {
if ((episode as any).custom_sid === 'vod-series') {
const mappedEpisode = episode as StalkerMappedEpisode;
if (mappedEpisode.custom_sid === 'vod-series') {
// It's a VOD series episode (is_series=1 mode)
// Use originalId for playback URL, but use generated id for tracking
this.playVodSeriesEpisode({
originalId: (episode as any).originalId, // For constructing playback URL
originalId: mappedEpisode.originalId, // For constructing playback URL
trackingId: episode.id, // The generated unique ID for position tracking
name: episode.title,
series_number: episode.episode_num,
@@ -375,7 +258,7 @@ export class StalkerSeriesViewComponent {
const trackingId = Number(episode.id);
this.playEpisodeClicked(
episode.episode_num,
(episode as any).originalCmd,
mappedEpisode.originalCmd,
trackingId
);
}
@@ -384,11 +267,15 @@ export class StalkerSeriesViewComponent {
/**
* Play episode - handles regular series and vclub mode
*/
playEpisodeClicked(episodeNum: any, cmd?: string, trackingId?: number) {
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
const item = this.displayItem();
console.log(
`[StalkerSeriesView] playEpisodeClicked: episodeNum=${episodeNum}, cmd=${cmd}, trackingId=${trackingId}, seriesId=${item.id}`
);
if (!item) return;
this.logger.debug('playEpisodeClicked', {
episodeNum,
cmd,
trackingId,
seriesId: item.id,
});
// Get playback position from XtreamStore if available
const position = trackingId
@@ -409,17 +296,26 @@ export class StalkerSeriesViewComponent {
/**
* Play VOD series episode
*/
playVodSeriesEpisode(episode: any) {
playVodSeriesEpisode(episode: {
originalId?: string;
trackingId: string;
name: string;
series_number: number;
}) {
const item = this.displayItem();
if (!item) return;
// Use originalId for playback URL (this is what the Stalker API expects)
const cmd = `/media/file_${episode.originalId}.mpg`;
const cmd = `/media/file_${episode.originalId ?? ''}.mpg`;
const episodeName = episode.name || `Episode ${episode.series_number}`;
// Use trackingId (generated unique ID) for playback position tracking
const trackingId = Number(episode.trackingId);
console.log(
`[StalkerSeriesView] playVodSeriesEpisode: originalId=${episode.originalId}, trackingId=${trackingId}, seriesId=${item.id}, episodeName=${episodeName}`
);
this.logger.debug('playVodSeriesEpisode', {
originalId: episode.originalId,
trackingId,
seriesId: item.id,
episodeName,
});
// Get playback position from XtreamStore if available
const position = this.xtreamStore
@@ -437,24 +333,16 @@ export class StalkerSeriesViewComponent {
);
}
addToFavorites(item: any) {
const displayItem = this.displayItem();
this.stalkerStore.addToFavorites({
...item,
title: displayItem.info.name,
cover: displayItem.info.movie_image,
series_id: displayItem.id,
added_at: new Date().toISOString(),
category_id: 'series',
});
}
removeFromFavorites(serialId: string) {
this.stalkerStore.removeFromFavorites(serialId);
}
goBack() {
this.backClicked.emit();
this.stalkerStore.clearSelectedItem();
}
}
toSeriesId(id: string | number): number {
const raw = String(id ?? '').trim();
if (!raw) return 0;
const primary = raw.includes(':') ? raw.split(':')[0] : raw;
const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
}
@@ -0,0 +1,90 @@
import {
isVodSeriesItem,
mapRegularSeriesEpisodes,
mapRegularSeriesSeasons,
mapVodSeriesEpisodes,
mapVodSeriesSeasonsToVm,
} from './stalker-series.adapters';
import { StalkerSelectedVodItem } from './models';
describe('stalker-series.adapters', () => {
it('detects vod-series flags from heterogeneous payloads', () => {
expect(isVodSeriesItem({ is_series: true })).toBe(true);
expect(isVodSeriesItem({ is_series: 1 })).toBe(true);
expect(isVodSeriesItem({ is_series: '1' })).toBe(true);
expect(isVodSeriesItem({ is_series: false })).toBe(false);
});
it('maps VOD-series seasons to VM shape with defaults', () => {
const seasons = mapVodSeriesSeasonsToVm([
{
id: '10',
video_id: '20',
season_number: '2',
},
]);
expect(seasons).toEqual([
{
id: '10',
video_id: '20',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
});
it('maps VOD-series episodes with stable tracking IDs and originalId metadata', () => {
const mapped = mapVodSeriesEpisodes(
[
{
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '1',
episodes: [
{
id: 'api-shared-id',
series_number: 1,
name: 'Episode 1',
},
{
id: 'api-shared-id',
series_number: 2,
name: 'Episode 2',
},
],
isLoading: false,
isExpanded: false,
},
],
'poster.jpg'
);
expect(mapped['1']).toHaveLength(2);
expect(mapped['1'][0].custom_sid).toBe('vod-series');
expect((mapped['1'][0] as any).originalId).toBe('api-shared-id');
expect(mapped['1'][0].id).not.toBe(mapped['1'][1].id);
});
it('maps embedded series payload into regular season episodes', () => {
const regularSeasons = mapRegularSeriesSeasons(
{
id: '100',
cmd: '/media/file_100.mpg',
series: ['1', 2, 'x'],
info: { name: 'Embedded Series' },
} as StalkerSelectedVodItem,
[]
);
const mapped = mapRegularSeriesEpisodes(regularSeasons, 'poster.jpg');
expect(regularSeasons[0].series).toEqual([1, 2]);
expect(mapped['1']).toHaveLength(2);
expect(mapped['1'][0].custom_sid).toBe('regular-series');
expect((mapped['1'][0] as any).originalCmd).toBe('/media/file_100.mpg');
});
});
@@ -0,0 +1,232 @@
import { XtreamSerieEpisode } from 'shared-interfaces';
import {
StalkerSeason,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
StalkerVodSource,
} from './models';
import { isStalkerSeriesFlag } from './stalker-vod.utils';
export interface VodSeriesSeasonVm {
id: string;
video_id: string;
name: string;
season_number: string;
episodes: StalkerVodSeriesEpisode[];
isLoading: boolean;
isExpanded: boolean;
}
export interface StalkerSeriesSeasonVm {
id: string;
name: string;
cmd?: string;
series: number[];
}
export interface StalkerMappedEpisode extends XtreamSerieEpisode {
originalId?: string;
originalCmd?: string;
}
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
function generateEpisodeId(
seed: string,
episodeNum: number,
seasonKey: string,
isVodSeries: boolean
): number {
if (isVodSeries) {
return hashString(`vod_${seasonKey}_${episodeNum}`);
}
return hashString(`${seed}_ep_${episodeNum}`);
}
function toEpisodeNumber(value: unknown): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function toNonEmptyString(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
return fallback;
}
function toSeriesNumbers(values: unknown[] | undefined): number[] {
if (!Array.isArray(values)) {
return [];
}
return values
.map((value) => toEpisodeNumber(value))
.filter((value) => value > 0);
}
export function isVodSeriesItem(
item: Pick<StalkerVodSource, 'is_series'> | null | undefined
): boolean {
return isStalkerSeriesFlag(item?.is_series);
}
export function mapVodSeriesSeasonsToVm(
seasons: StalkerVodSeriesSeason[] | undefined
): VodSeriesSeasonVm[] {
return (seasons ?? []).map((season) => ({
id: String(season.id),
video_id: String(season.video_id),
name: toNonEmptyString(
season.name,
`Season ${toNonEmptyString(season.season_number, '0')}`
),
season_number: toNonEmptyString(season.season_number, ''),
episodes: [],
isLoading: false,
isExpanded: false,
}));
}
export function mapRegularSeriesSeasons(
vodWithSeries: StalkerVodSource | null,
serialSeasons: StalkerSeason[] | undefined
): StalkerSeriesSeasonVm[] {
if (vodWithSeries?.series && vodWithSeries.series.length > 0) {
return [
{
id: String(vodWithSeries.id),
name: vodWithSeries.info?.name || 'Episodes',
cmd: vodWithSeries.cmd,
series: toSeriesNumbers(vodWithSeries.series),
},
];
}
return (serialSeasons ?? []).map((season) => ({
id: String(season.id),
name: season.name,
cmd: season.cmd,
series: toSeriesNumbers(season.series),
}));
}
function createBaseEpisode(
id: number,
episodeNum: number,
title: string,
containerExtension: string,
customSid: string,
season: number,
info: XtreamSerieEpisode['info']
): XtreamSerieEpisode {
return {
id: String(id),
episode_num: episodeNum,
title,
container_extension: containerExtension,
info,
custom_sid: customSid,
added: '',
season,
direct_source: '',
};
}
export function mapVodSeriesEpisodes(
seasons: ReadonlyArray<VodSeriesSeasonVm>,
fallbackPoster?: string
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
seasons.forEach((season) => {
const seasonKey = season.season_number || season.name || season.id;
const seasonNum = toEpisodeNumber(seasonKey) || 1;
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
const episodeNum =
toEpisodeNumber(episode.series_number) ||
toEpisodeNumber(episode.episode_num);
const trackingId = generateEpisodeId(
String(episode.id ?? ''),
episodeNum,
seasonKey,
true
);
return {
...createBaseEpisode(
trackingId,
episodeNum,
episode.name || `Episode ${episodeNum}`,
'mpg',
'vod-series',
seasonNum,
{
movie_image: episode.cover || fallbackPoster,
plot: episode.description || '',
duration: episode.duration
? `${episode.duration} min`
: '',
}
),
originalId: String(episode.id ?? ''),
} as StalkerMappedEpisode;
});
});
return mapped;
}
export function mapRegularSeriesEpisodes(
seasons: ReadonlyArray<StalkerSeriesSeasonVm>,
fallbackPoster?: string
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
seasons.forEach((season, index) => {
const seasonKey = String(index + 1);
mapped[seasonKey] = (season.series ?? []).map((episodeNum) => {
const trackingId = generateEpisodeId(
String(season.cmd ?? ''),
episodeNum,
seasonKey,
false
);
return {
...createBaseEpisode(
trackingId,
episodeNum,
`Episode ${episodeNum}`,
'',
'regular-series',
Number(seasonKey),
{
movie_image: fallbackPoster,
}
),
originalCmd: season.cmd,
} as StalkerMappedEpisode;
});
});
return mapped;
}
export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string {
return season.season_number || season.name || season.id;
}
@@ -0,0 +1,109 @@
import { VodDetailsItem } from 'shared-interfaces';
import {
createStalkerDetailViewState,
toggleStalkerVodFavorite,
} from './stalker-vod.utils';
describe('stalker-vod.utils regressions', () => {
it('routes embedded series[] items to series view state', () => {
const state = createStalkerDetailViewState(
{
id: '101',
cmd: '/media/file_101.mpg',
series: [1, 2, 3],
info: {
name: 'Series in VOD',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'playlist-1'
);
expect(state.itemDetails?.series?.length).toBe(3);
expect(state.vodDetailsItem).toBeNull();
});
it('routes is_series=1 items to series view state (lazy season flow)', () => {
const state = createStalkerDetailViewState(
{
id: '1507',
cmd: '/media/file_1507.mpg',
is_series: true,
info: {
name: 'Flagged Series',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'playlist-1'
);
expect(state.itemDetails?.is_series).toBe(true);
expect(state.vodDetailsItem).toBeNull();
});
it('favorite toggle uses completion callback path without delayed state update', () => {
const addToFavorites = jest.fn(
(_item: Record<string, unknown>, onDone?: () => void) => {
onDone?.();
}
);
const removeFromFavorites = jest.fn(
(_id: string, onDone?: () => void) => {
onDone?.();
}
);
const onComplete = jest.fn();
const item = {
type: 'stalker',
data: {
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
playlistId: 'playlist-1',
vodId: 42,
cmd: '/media/file_42.mpg',
} as unknown as VodDetailsItem;
toggleStalkerVodFavorite(
{ item, isFavorite: true },
{ addToFavorites, removeFromFavorites, onComplete }
);
expect(addToFavorites).toHaveBeenCalledTimes(1);
expect(removeFromFavorites).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalledTimes(1);
onComplete.mockClear();
toggleStalkerVodFavorite(
{ item, isFavorite: false },
{ addToFavorites, removeFromFavorites, onComplete }
);
expect(removeFromFavorites).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,328 @@
import {
signal,
WritableSignal,
} from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { PlaylistsService } from 'services';
import {
createStalkerVodItem,
StalkerVodDetails,
StalkerVodInfo,
VodDetailsItem,
} from 'shared-interfaces';
import {
StalkerFavoriteItem,
StalkerSelectedVodItem,
StalkerSeriesFlag,
StalkerVodSource,
} from './models/stalker-favorite-item.interface';
export interface StalkerDetailViewState {
itemDetails: StalkerSelectedVodItem | null;
vodDetailsItem: VodDetailsItem | null;
}
export interface StalkerFavoriteToggleEvent {
item: VodDetailsItem;
isFavorite: boolean;
}
export interface StalkerFavoriteToggleOptions {
addToFavorites: (
item: Record<string, unknown>,
onComplete?: () => void
) => void;
removeFromFavorites: (
favoriteId: string,
onComplete?: () => void
) => void;
onComplete?: () => void;
}
export function isStalkerSeriesFlag(value: unknown): boolean {
return value === true || value === 1 || value === '1';
}
export function normalizeStalkerSeriesFlag(
value: unknown
): StalkerSeriesFlag | undefined {
if (value === true || value === 1 || value === '1') {
return true;
}
return undefined;
}
export function isStalkerSeriesItem(item: {
is_series?: unknown;
series?: unknown;
}): boolean {
return (
isStalkerSeriesFlag(item?.is_series) ||
(Array.isArray(item?.series) && item.series.length > 0)
);
}
export function buildStalkerFavoritePayload(
data: StalkerVodDetails
): Record<string, unknown> {
return {
...data,
category_id: 'vod',
title: data.info?.name,
cover: data.info?.movie_image,
added_at: new Date().toISOString(),
};
}
export function matchesFavoriteById(
favorite: Partial<StalkerFavoriteItem>,
itemId: string | number
): boolean {
const expectedId = normalizeStalkerEntityId(itemId);
return (
normalizeStalkerEntityId(favorite?.id) === expectedId ||
normalizeStalkerEntityId(favorite?.movie_id) === expectedId ||
normalizeStalkerEntityId(favorite?.stream_id) === expectedId ||
normalizeStalkerEntityId(favorite?.series_id) === expectedId
);
}
export function normalizeStalkerEntityId(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
export function normalizeStalkerEntityIdAsNumber(
value: unknown
): number | null {
const normalized = normalizeStalkerEntityId(value);
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function toStringOrFallback(value: unknown, fallback = ''): string {
if (typeof value === 'string') return value;
if (value === null || value === undefined) return fallback;
return String(value);
}
function toOptionalString(value: unknown): string | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
return String(value);
}
export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
const info = item.info ?? {};
return {
name: toStringOrFallback(
info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title,
'Unknown'
),
o_name: toOptionalString(info.o_name ?? item.o_name),
movie_image: toStringOrFallback(
info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo
),
description: toStringOrFallback(info.description ?? item.description),
actors: toStringOrFallback(info.actors ?? item.actors),
director: toStringOrFallback(info.director ?? item.director),
releasedate: toStringOrFallback(
info.releasedate ?? item.releasedate ?? item.year
),
genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str),
rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb),
rating_kinopoisk: toStringOrFallback(
info.rating_kinopoisk ?? item.rating_kinopoisk
),
};
}
export function buildStalkerSelectedVodItem(
item: StalkerVodSource,
forceSeries = false
): StalkerSelectedVodItem {
return {
id: toStringOrFallback(item.id ?? item.stream_id),
cmd: toStringOrFallback(item.cmd),
series: item.series,
has_files: item.has_files,
is_series:
forceSeries || isStalkerSeriesFlag(item?.is_series)
? true
: undefined,
video_id: item.video_id,
category_id:
typeof item.category_id === 'string'
? item.category_id
: undefined,
info: createStalkerInfo(item),
};
}
export interface NormalizedStalkerFavoriteItem extends StalkerFavoriteItem {
details: StalkerSelectedVodItem;
}
export function normalizeStalkerFavoriteItem(
item: StalkerFavoriteItem
): NormalizedStalkerFavoriteItem {
if (item.details && item.details.info) {
return item as NormalizedStalkerFavoriteItem;
}
const source = (item.details || item) as StalkerVodSource;
const info = createStalkerInfo({
...item,
...source,
});
const normalizedIsSeries = normalizeStalkerSeriesFlag(
(item as StalkerVodSource).is_series ?? source.is_series
);
return {
...item,
details: {
...source,
info,
cmd: toStringOrFallback(
(item as StalkerVodSource).cmd ?? source.cmd
),
id: toStringOrFallback(item.stream_id ?? item.id ?? source.id),
series: (item as StalkerVodSource).series ?? source.series,
is_series: normalizedIsSeries ? true : undefined,
video_id:
(item as StalkerVodSource).video_id ?? source.video_id,
category_id:
typeof (item as StalkerVodSource).category_id === 'string'
? (item as StalkerVodSource).category_id
: typeof source.category_id === 'string'
? source.category_id
: undefined,
},
};
}
export function normalizeStalkerVodDetailsItem(
item: StalkerVodSource
): StalkerSelectedVodItem {
return {
...buildStalkerSelectedVodItem(item),
info: createStalkerInfo(item),
is_series: normalizeStalkerSeriesFlag(item?.is_series)
? true
: undefined,
};
}
export function createStalkerDetailViewState(
normalizedItem: StalkerSelectedVodItem,
playlistId: string
): StalkerDetailViewState {
if (isStalkerSeriesItem(normalizedItem)) {
return {
itemDetails: normalizedItem,
vodDetailsItem: null,
};
}
return {
itemDetails: normalizedItem,
vodDetailsItem: createStalkerVodItem(
normalizedItem as StalkerVodDetails,
playlistId
),
};
}
export function clearStalkerDetailViewState(): StalkerDetailViewState {
return {
itemDetails: null,
vodDetailsItem: null,
};
}
export function createRefreshTrigger(): {
refreshVersion: WritableSignal<number>;
refresh: () => void;
} {
const refreshVersion = signal<number>(Date.now());
return {
refreshVersion,
refresh: () => refreshVersion.set(Date.now()),
};
}
export function createPortalFavoritesResource(
playlistService: PlaylistsService,
getPortalId: () => string | undefined,
getRefreshVersion: () => number
) {
return createPortalCollectionResource(
playlistService,
getPortalId,
getRefreshVersion,
(service, portalId) => service.getPortalFavorites(portalId)
);
}
export function createPortalCollectionResource<T>(
playlistService: PlaylistsService,
getPortalId: () => string | undefined,
getRefreshVersion: () => number,
streamFactory: (
playlistService: PlaylistsService,
portalId: string
) => Observable<T[]>
) {
return rxResource({
params: () => {
const portalId = getPortalId();
if (!portalId) return undefined;
return {
portalId,
refreshVersion: getRefreshVersion(),
};
},
stream: ({ params }) =>
streamFactory(playlistService, params.portalId),
});
}
export function isSelectedStalkerVodFavorite(
item: VodDetailsItem | null,
favorites: ReadonlyArray<StalkerFavoriteItem> | undefined
): boolean {
if (!item || item.type !== 'stalker') {
return false;
}
return (favorites ?? []).some((favorite) =>
matchesFavoriteById(favorite, item.data.id)
);
}
export function toggleStalkerVodFavorite(
event: StalkerFavoriteToggleEvent,
options: StalkerFavoriteToggleOptions
): void {
if (event.item.type !== 'stalker') {
return;
}
if (event.isFavorite) {
options.addToFavorites(
buildStalkerFavoritePayload(event.item.data),
options.onComplete
);
} else {
options.removeFromFavorites(event.item.data.id, options.onComplete);
}
}
@@ -0,0 +1,161 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
DataService,
PlaylistsService,
StalkerSessionService,
} from 'services';
import { PlayerService } from '../services/player.service';
import { StalkerStore } from './stalker.store';
describe('StalkerStore API compatibility smoke', () => {
let store: any;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
StalkerStore,
{
provide: DataService,
useValue: {
isElectron: false,
sendIpcEvent: jest.fn(),
},
},
{
provide: StalkerSessionService,
useValue: {
ensureToken: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
},
},
{
provide: PlayerService,
useValue: {
openPlayer: jest.fn(),
},
},
{
provide: PlaylistsService,
useValue: {
addPortalFavorite: jest.fn(() => of(undefined)),
removeFromPortalFavorites: jest.fn(() => of(undefined)),
addPortalRecentlyViewed: jest.fn(() => of(undefined)),
removeFromPortalRecentlyViewed: jest.fn(() =>
of(undefined)
),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
store = TestBed.inject(StalkerStore);
});
it('exposes compatibility state signals', () => {
const expectedSignals = [
'selectedContentType',
'selectedCategoryId',
'selectedVodId',
'selectedSerialId',
'selectedItvId',
'limit',
'page',
'searchPhrase',
'currentPlaylist',
'totalCount',
'selectedItem',
'vodCategories',
'seriesCategories',
'itvCategories',
'hasMoreChannels',
'itvChannels',
'vodSeriesSeasons',
'vodSeriesEpisodes',
'selectedVodSeriesSeasonId',
];
for (const signalName of expectedSignals) {
expect(typeof store[signalName]).toBe('function');
}
});
it('exposes compatibility computed selectors', () => {
const expectedComputed = [
'getTotalPages',
'getPaginatedContent',
'isPaginatedContentLoading',
'isPaginatedContentFailed',
'getSerialSeasonsResource',
'isSerialSeasonsLoading',
'getVodSeriesSeasonsResource',
'isVodSeriesSeasonsLoading',
'getCategoryResource',
'isCategoryResourceLoading',
'isCategoryResourceFailed',
'getSelectedCategoryName',
];
for (const computedName of expectedComputed) {
expect(typeof store[computedName]).toBe('function');
}
});
it('exposes compatibility methods and internal resources', () => {
const expectedMethods = [
'setSelectedContentType',
'setSelectedCategory',
'setSelectedSerialId',
'setSelectedVodId',
'setSelectedItvId',
'setLimit',
'setPage',
'setCurrentPlaylist',
'setSelectedItem',
'clearSelectedItem',
'setCategories',
'resetCategories',
'setItvChannels',
'setSearchPhrase',
'fetchVodSeriesEpisodes',
'getSelectedCategory',
'fetchLinkToPlay',
'getExpireDate',
'addToFavorites',
'removeFromFavorites',
'fetchMovieFileId',
'createLinkToPlayVod',
'addToRecentlyViewed',
'removeFromRecentlyViewed',
'fetchChannelEpg',
'makeStalkerRequest',
];
for (const methodName of expectedMethods) {
expect(typeof store[methodName]).toBe('function');
}
expect(store.getContentResource).toBeDefined();
expect(typeof store.getContentResource.value).toBe('function');
expect(store.serialSeasonsResource).toBeDefined();
expect(typeof store.serialSeasonsResource.value).toBe('function');
expect(store.vodSeriesSeasonsResource).toBeDefined();
expect(typeof store.vodSeriesSeasonsResource.value).toBe('function');
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,9 @@
export * from './with-stalker-content.feature';
export * from './with-stalker-epg.feature';
export * from './with-stalker-favorites.feature';
export * from './with-stalker-player.feature';
export * from './with-stalker-portal.feature';
export * from './with-stalker-recent.feature';
export * from './with-stalker-selection.feature';
export * from './with-stalker-series.feature';
@@ -0,0 +1,326 @@
import { computed, inject, resource } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import {
StalkerCategoryItem,
StalkerContentItem,
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { createLogger } from '../../../shared/utils/logger';
import { toStalkerContentItem, toStalkerItvChannel } from '../utils';
/**
* Content/categories/channels feature state.
*/
export interface StalkerContentState {
totalCount: number;
vodCategories: StalkerCategoryItem[];
seriesCategories: StalkerCategoryItem[];
itvCategories: StalkerCategoryItem[];
hasMoreChannels: boolean;
itvChannels: StalkerItvChannel[];
}
const initialContentState: StalkerContentState = {
totalCount: 0,
vodCategories: [],
seriesCategories: [],
itvCategories: [],
hasMoreChannels: false,
itvChannels: [],
};
export function withStalkerContent() {
const logger = createLogger('withStalkerContent');
return signalStoreFeature(
withState<StalkerContentState>(initialContentState),
withProps(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
getCategoryResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: (store as any).currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerCategoryItem[]> => {
if (params.currentPlaylist === undefined) return;
switch (params.contentType) {
case 'itv':
if (store.itvCategories().length > 0) {
return store.itvCategories();
}
break;
case 'vod':
if (store.vodCategories().length > 0) {
return store.vodCategories();
}
break;
case 'series':
if (store.seriesCategories().length > 0) {
return store.seriesCategories();
}
break;
}
const { portalUrl, macAddress } = params.currentPlaylist;
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = params.currentPlaylist as Playlist;
const queryParams = {
action: StalkerContentTypes[params.contentType]
.getCategoryAction,
type: params.contentType,
};
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js || !Array.isArray(response.js)) {
logger.warn('Invalid categories response', response);
return [];
}
const categories = response.js
.map((item): StalkerCategoryItem => ({
category_name: item.title,
category_id: String(item.id),
}))
.sort((a, b) =>
a.category_name.localeCompare(b.category_name)
);
patchState(store, {
[`${params.contentType}Categories`]: categories,
});
return categories;
},
}),
getContentResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
category: (store as any).selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: (store as any).searchPhrase(),
pageIndex: (store as any).page() + 1,
}),
loader: async ({
params,
}): Promise<StalkerContentItem[] | undefined> => {
if (
!params.category ||
params.category === null ||
params.category === ''
) {
return Promise.resolve(undefined);
}
const currentPlaylist = (store as any).currentPlaylist;
// Guard: ensure currentPlaylist is available (may not be during deep link init)
if (!currentPlaylist() || !currentPlaylist().portalUrl) {
return Promise.resolve(undefined);
}
// VOD uses 'genre' param, series uses 'category' param, itv uses both
// Based on stalker-to-m3u implementation
// Use "*" for categories without an ID (e.g. "All") to fetch all items
const categoryParam = params.category || '*';
const queryParams: Record<string, string | number> = {
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: params.contentType,
sortby: 'added',
...(params.search !== ''
? { search: params.search }
: {}),
p: params.pageIndex,
};
// Add the correct category/genre param based on content type
// Based on working app traces: VOD uses genre=0 and category={id}
if (params.contentType === 'vod') {
queryParams['genre'] = '0';
queryParams['category'] = categoryParam;
} else if (params.contentType === 'series') {
queryParams['category'] = categoryParam;
} else {
// itv - use both for compatibility
queryParams['category'] = categoryParam;
queryParams['genre'] = categoryParam;
}
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = currentPlaylist() as Playlist;
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js?.data) {
logger.warn('Invalid response structure', response);
return [];
}
patchState(store, {
totalCount: response.js.total_items ?? 0,
});
const portalUrl = currentPlaylist().portalUrl;
const newItems = response.js.data.map(
(item: StalkerVodSource) =>
toStalkerContentItem(item, portalUrl)
);
if ((store as any).selectedContentType() === 'itv') {
const channels = newItems.map(toStalkerItvChannel);
// Check if we're loading the first page or loading more
if (params.pageIndex === 1) {
patchState(store, { itvChannels: channels });
} else {
patchState(store, {
itvChannels: [
...store.itvChannels(),
...channels,
],
});
}
// Update hasMoreItems based on total count and current items
const totalLoaded = store.itvChannels().length;
patchState(store, {
hasMoreChannels:
totalLoaded < (response.js.total_items ?? 0),
});
}
return newItems;
},
}),
})
),
withComputed((store) => {
const storeAny = store as any;
return {
getTotalPages: computed(() => {
return Math.ceil(store.totalCount() / storeAny.limit());
}),
getSelectedCategoryName: computed(() => {
const type = storeAny.selectedContentType();
const selectedCategoryId = storeAny.selectedCategoryId();
if (!selectedCategoryId) return '';
let categories = [];
if (type === 'vod') {
categories = store.vodCategories();
} else if (type === 'series') {
categories = store.seriesCategories();
} else if (type === 'itv') {
categories = store.itvCategories();
}
const category = categories.find(
(cat) =>
String(cat.category_id) ===
String(selectedCategoryId)
);
return category ? category.category_name : '';
}),
/** category content */
getPaginatedContent: computed(() =>
storeAny.getContentResource.value()
),
isPaginatedContentLoading: computed(() =>
storeAny.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() =>
storeAny.getContentResource.error()
),
/** category resource */
getCategoryResource: computed(() =>
storeAny.getCategoryResource.value()
),
isCategoryResourceLoading: computed(() =>
storeAny.getCategoryResource.isLoading()
),
isCategoryResourceFailed: computed(() =>
storeAny.getCategoryResource.error()
),
};
}),
withMethods((store) => ({
setCategories(
type: 'vod' | 'series' | 'itv',
categories: StalkerCategoryItem[]
) {
if (type === 'vod') {
patchState(store, { vodCategories: categories });
} else if (type === 'series') {
patchState(store, { seriesCategories: categories });
} else if (type === 'itv') {
patchState(store, { itvCategories: categories });
}
},
resetCategories() {
patchState(store, {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
});
},
setItvChannels(channels: StalkerItvChannel[]) {
patchState(store, { itvChannels: channels });
},
}))
);
}
@@ -0,0 +1,78 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import {
EpgItem,
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
/**
* EPG concern methods.
*/
export function withStalkerEpg() {
return signalStoreFeature(
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
return {
async fetchChannelEpg(
channelId: number | string,
size = 10
): Promise<EpgItem[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams: Record<string, string> = {
action: StalkerPortalActions.GetShortEpg,
type: 'itv',
ch_id: String(channelId),
size: String(size),
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
const epgData = response?.js?.data ?? response?.js ?? [];
const items = Array.isArray(epgData) ? epgData : [];
return items.map((item) => ({
id: String(item.id ?? ''),
epg_id: '',
title: item.name ?? '',
description: item.descr ?? '',
lang: '',
start: item.time ?? '',
end: item.time_to ?? '',
stop: item.time_to ?? '',
channel_id: String(item.ch_id ?? channelId),
start_timestamp: String(item.start_timestamp ?? ''),
stop_timestamp: String(item.stop_timestamp ?? ''),
}));
},
};
}
)
);
}
@@ -0,0 +1,72 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { withMethods, signalStoreFeature } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
/**
* Favorites concern methods.
*/
export function withStalkerFavorites() {
return signalStoreFeature(
withMethods(
(
store,
playlistService = inject(PlaylistsService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
) => {
const storeAny = store as any;
return {
addToFavorites(item: any, onDone?: () => void) {
playlistService
.addPortalFavorite(
storeAny.currentPlaylist()?._id,
{
...item,
category_id:
storeAny.selectedContentType(),
added_at: Date.now(),
id: item.stream_id ?? item.id,
}
)
.subscribe(() => {
snackBar.open(
translate.instant(
'PORTALS.ADDED_TO_FAVORITES'
),
null,
{
duration: 1000,
}
);
onDone?.();
});
},
removeFromFavorites(
favoriteId: string,
onDone?: () => void
) {
playlistService
.removeFromPortalFavorites(
storeAny.currentPlaylist()?._id,
favoriteId
)
.subscribe(() => {
snackBar.open(
translate.instant(
'PORTALS.REMOVED_FROM_FAVORITES'
),
null,
{
duration: 1000,
}
);
onDone?.();
});
},
};
}
)
);
}
@@ -0,0 +1,390 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import { PlayerService } from '../../../services/player.service';
import { ContentType } from '../../../xtream/content-type.enum';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
} from '../../stalker-vod.utils';
import { createLogger } from '../../../shared/utils/logger';
/**
* Playback/link/player concern methods.
*/
export function withStalkerPlayer() {
const logger = createLogger('withStalkerPlayer');
return signalStoreFeature(
withMethods(
(
store,
dataService = inject(DataService),
playlistService = inject(PlaylistsService),
playerService = inject(PlayerService),
stalkerSession = inject(StalkerSessionService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
) => {
const storeAny = store as any;
const fetchLinkToPlayInternal = async (
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
) => {
const type = series
? ContentType.VODS
: storeAny.selectedContentType();
// Always use create_link to get the tokenized streaming URL
// The server adds the required token for playback authorization
// Note: cmd is already transformed during item processing (has_files items)
const params = {
action: StalkerContentTypes[
storeAny.selectedContentType()
].getLink,
cmd: cmd,
type,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
...(series ? { series: String(series) } : {}),
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeAny.currentPlaylist() as Playlist;
let response: any;
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
}
);
}
// Check for server-side errors
if (response.js?.error) {
const errorMsg = response.js.error;
logger.error('Server error', errorMsg);
throw new Error(errorMsg);
}
let url = response.js.cmd as string;
// If cmd is empty, the content is not available
if (!url) {
throw new Error('nothing_to_play');
}
if (url.startsWith('ffmpeg')) {
url = url.split(' ')[1];
}
// Handle incomplete URLs - some portals return just query params or relative paths
if (
url &&
!url.startsWith('http://') &&
!url.startsWith('https://')
) {
// Extract base URL from portal URL
try {
const portalUrlObj = new URL(portalUrl);
// Get the stalker portal base path (e.g., /stalker_portal from /stalker_portal/server/load.php)
const pathParts = portalUrlObj.pathname.split('/');
// Find the stalker_portal or c directory and use that as base
let basePath = '';
for (let i = 0; i < pathParts.length; i++) {
if (
pathParts[i] === 'stalker_portal' ||
pathParts[i] === 'c' ||
pathParts[i] === 'portal'
) {
basePath =
'/' + pathParts.slice(1, i + 1).join('/');
break;
}
}
// If url starts with ?, it's just query params
// Combine with the original cmd path to form the complete streaming URL
if (url.startsWith('?')) {
// The streaming URL is: portal origin + base path + original cmd path + token query
// e.g., http://portal.com + /stalker_portal + /media/12345.mpg + ?token=xxx
url = `${portalUrlObj.origin}${basePath}${cmd}${url}`;
} else if (url.startsWith('/')) {
// Relative path - prepend origin and base path
url = `${portalUrlObj.origin}${basePath}${url}`;
}
} catch {
// URL parsing failed, return as-is
}
}
return url;
};
const fetchMovieFileIdInternal = async (
movieId: string
): Promise<string | null> => {
const playlist = storeAny.currentPlaylist() as Playlist;
if (!playlist) return null;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: movieId,
p: '1',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Extract id from the first data item
if (response?.js?.data?.[0]?.id) {
const fileId = response.js.data[0].id;
return String(fileId);
}
return null;
};
const addToRecentlyViewedInternal = (
item: any,
cmd?: string,
cover?: string,
title?: string
) => {
const playlistId = storeAny.currentPlaylist()?._id;
if (!playlistId) return;
playlistService
.addPortalRecentlyViewed(playlistId, {
...item,
id: item?.id,
cmd,
cover,
title,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
})
.subscribe();
};
return {
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
) {
return fetchLinkToPlayInternal(
portalUrl,
macAddress,
cmd,
series
);
},
async getExpireDate() {
const params = {
type: 'account_info',
action: 'get_main_info',
JsHttpRequest: '1-xml',
};
try {
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeAny.currentPlaylist() as Playlist;
let response: any;
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
}
);
}
if (
response &&
response.js &&
response.js.account_info
) {
// Extract the expire date from the response
const expireDate =
response.js.account_info.expire_date;
// Convert timestamp to readable date if it's a unix timestamp
if (expireDate && !isNaN(expireDate)) {
const date = new Date(expireDate * 1000); // Convert seconds to milliseconds
return date.toLocaleDateString();
}
return expireDate || 'Unknown';
}
return 'Unknown';
} catch (error) {
logger.error('Failed to fetch expire date', error);
return 'Error fetching data';
}
},
/**
* Fetch movie files using get_ordered_list with movie_id parameter.
* This is needed for items with has_files property to get the correct video_id
* for the create_link request.
*/
async fetchMovieFileId(
movieId: string
): Promise<string | null> {
return fetchMovieFileIdInternal(movieId);
},
/**
* Play VOD or episode content
* @param cmd The media command/path
* @param title Display title
* @param thumbnail Thumbnail URL
* @param episodeNum Episode number (for series param in API)
* @param episodeId Optional episode ID for playback tracking (defaults to item.id)
* @param startTime Optional start time in seconds for resume playback
*/
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
) {
try {
const item = storeAny.selectedItem();
let cmdToUse = cmd ?? item?.cmd;
if (!cmdToUse) {
throw new Error('nothing_to_play');
}
// For items with has_files and relative path, we need to fetch the file id first
if (
item?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId = await fetchMovieFileIdInternal(
normalizeStalkerEntityId(item.id)
);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const playlist = storeAny.currentPlaylist();
const url = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
cmdToUse,
episodeNum
);
if (typeof storeAny.addToRecentlyViewed === 'function') {
storeAny.addToRecentlyViewed({
...item,
id: item?.id,
cmd: cmd,
cover: thumbnail,
title,
});
} else {
addToRecentlyViewedInternal(
item,
cmd,
thumbnail,
title
);
}
const isEpisode =
episodeNum !== undefined ||
episodeId !== undefined;
const selectedItemId =
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
const contentInfo = {
playlistId: playlist._id,
// For episodes, use episodeId if provided, otherwise fall back to item.id
contentXtreamId:
isEpisode && episodeId
? episodeId
: selectedItemId,
contentType: isEpisode ? 'episode' : 'vod',
seriesXtreamId: isEpisode
? selectedItemId
: undefined,
};
playerService.openPlayer(
url,
title,
thumbnail,
true,
false,
playlist?.userAgent,
playlist?.referrer,
playlist?.origin,
contentInfo,
startTime
);
} catch (error) {
logger.error('Failed to get playback URL', error);
const errorMessage =
error?.message === 'nothing_to_play'
? translate.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: translate.instant(
'PORTALS.PLAYBACK_ERROR'
);
snackBar.open(errorMessage, null, {
duration: 3000,
});
}
},
};
}
)
);
}
@@ -0,0 +1,105 @@
import { inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withProps,
withMethods,
withState,
} from '@ngrx/signals';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
import { DataService, StalkerSessionService } from 'services';
import { createLogger } from '../../../shared/utils/logger';
/**
* Portal/session state and methods.
*/
export interface StalkerPortalState {
currentPlaylist: PlaylistMeta;
}
const initialPortalState: StalkerPortalState = {
currentPlaylist: undefined,
};
export function withStalkerPortal() {
const logger = createLogger('withStalkerPortal');
return signalStoreFeature(
withState<StalkerPortalState>(initialPortalState),
withProps(
(
_store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
/**
* Helper to make stalker requests with automatic token handling
*/
async makeStalkerRequest(
playlist: PlaylistMeta,
params: Record<string, any>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if ((playlist as Playlist).isFullStalkerPortal) {
try {
const result = await stalkerSession.ensureToken(
playlist as Playlist
);
token = result.token ?? undefined;
serialNumber = (playlist as Playlist)
.stalkerSerialNumber;
} catch (error) {
logger.error('Failed to get stalker token', error);
}
}
return dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
});
},
})
),
withMethods((store, dataService = inject(DataService)) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
dataService.isElectron &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
) {
try {
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await window.electron.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
} catch (error) {
logger.error(
'Error syncing Stalker playlist to SQLite',
error
);
}
}
},
}))
);
}
@@ -0,0 +1,49 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { PlaylistsService } from 'services';
import { createLogger } from '../../../shared/utils/logger';
/**
* Recently-viewed concern methods.
*/
export function withStalkerRecent() {
const logger = createLogger('withStalkerRecent');
return signalStoreFeature(
withMethods((store, playlistService = inject(PlaylistsService)) => {
const storeAny = store as any;
return {
addToRecentlyViewed(item: any) {
playlistService
.addPortalRecentlyViewed(
storeAny.currentPlaylist()?._id,
{
...item,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
}
)
.subscribe();
},
removeFromRecentlyViewed(
itemId: string | number,
onComplete?: () => void
) {
playlistService
.removeFromPortalRecentlyViewed(
storeAny.currentPlaylist()?._id,
itemId
)
.subscribe({
next: () => onComplete?.(),
error: (error) =>
logger.error(
'Failed to remove item from recently viewed',
error
),
});
},
};
})
);
}
@@ -0,0 +1,135 @@
import {
patchState,
signalStoreFeature,
withMethods,
withState,
} from '@ngrx/signals';
import { computed } from '@angular/core';
import { StalkerVodSource } from '../../models';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
/**
* Selection/pagination/search feature state.
*/
export interface StalkerSelectionState {
selectedContentType: 'vod' | 'itv' | 'series';
selectedCategoryId: string | null | undefined;
selectedVodId: string | undefined;
selectedSerialId: string | undefined;
selectedItvId: string | undefined;
limit: number;
page: number;
searchPhrase: string;
selectedItem: StalkerVodSource | null | undefined;
}
const initialSelectionState: StalkerSelectionState = {
selectedContentType: 'vod',
selectedCategoryId: undefined,
selectedVodId: undefined,
selectedSerialId: undefined,
selectedItvId: undefined,
limit: 14,
page: 0,
searchPhrase: '',
selectedItem: undefined,
};
export function withStalkerSelection() {
return signalStoreFeature(
withState<StalkerSelectionState>(initialSelectionState),
withMethods((store) => ({
setSelectedContentType(type: 'vod' | 'itv' | 'series') {
patchState(store, { selectedContentType: type });
},
setSelectedCategory(id: string | number | null) {
patchState(store, {
selectedCategoryId:
id !== null && id !== undefined
? String(id)
: null,
page: 0,
});
},
setSelectedSerialId(id: string) {
patchState(store, { selectedSerialId: id });
},
setSelectedVodId(id: string) {
patchState(store, { selectedVodId: id });
},
setSelectedItvId(id: string) {
patchState(store, { selectedItvId: id });
},
setLimit(limit: number) {
patchState(store, { limit });
},
setPage(page: number) {
patchState(store, { page });
},
setSearchPhrase(phrase: string) {
patchState(store, { searchPhrase: phrase, page: 0 });
},
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
const selectedIdRaw =
selectedItem?.id !== undefined &&
selectedItem?.id !== null
? selectedItem.id
: undefined;
const selectedId =
selectedIdRaw !== undefined
? normalizeStalkerEntityId(selectedIdRaw)
: undefined;
patchState(store, {
selectedVodId: selectedId,
selectedSerialId: selectedId,
selectedItvId: selectedId,
selectedItem,
});
},
clearSelectedItem() {
patchState(store, {
selectedVodId: undefined,
selectedSerialId: undefined,
selectedItvId: undefined,
selectedItem: undefined,
});
},
/** getters */
getSelectedCategory: computed(() => {
const storeAny = store as any;
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return {
id: 0,
name: 'All Items',
type: store.selectedContentType(),
};
}
// Get categories based on content type
const contentType = store.selectedContentType();
let categories: any[] = [];
const readCategories = (
getterName: 'vodCategories' | 'seriesCategories' | 'itvCategories'
) =>
typeof storeAny[getterName] === 'function'
? storeAny[getterName]()
: [];
if (contentType === 'vod') {
categories = readCategories('vodCategories');
} else if (contentType === 'series') {
categories = readCategories('seriesCategories');
} else if (contentType === 'itv') {
categories = readCategories('itvCategories');
}
return (
categories.find(
(c: any) =>
String(c.category_id) === String(categoryId)
) || { id: categoryId, name: '', type: contentType }
);
}),
}))
);
}
@@ -0,0 +1,277 @@
import { computed, inject, resource } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import {
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
sortByNumericValue,
sortEpisodesByNumber,
sortVodSeriesSeasonsByNumber,
} from '../utils';
import { createLogger } from '../../../shared/utils/logger';
/**
* Regular-series and VOD-series feature state.
*/
export interface StalkerSeriesState {
/** For VOD items that are actually series (Ministra plugin is_series=1) */
vodSeriesSeasons: StalkerVodSeriesSeason[];
vodSeriesEpisodes: StalkerVodSeriesEpisode[];
selectedVodSeriesSeasonId: string | undefined;
}
const initialSeriesState: StalkerSeriesState = {
vodSeriesSeasons: [],
vodSeriesEpisodes: [],
selectedVodSeriesSeasonId: undefined,
};
export function withStalkerSeries() {
const logger = createLogger('withStalkerSeries');
const toMovieId = (value: unknown): string => {
const raw = String(value ?? '').trim();
if (!raw) return '';
return raw.includes(':') ? raw.split(':')[0] : raw;
};
return signalStoreFeature(
withState<StalkerSeriesState>(initialSeriesState),
withProps(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
serialSeasonsResource: resource({
params: () => ({
itemId: (store as any).selectedSerialId(),
}),
loader: async ({ params }) => {
const movieId = toMovieId(params.itemId);
// Guard: ensure currentPlaylist and itemId are available
if (!(store as any).currentPlaylist() || !movieId) {
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
movie_id: movieId,
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js?.data) {
logger.warn('Invalid seasons response', response);
return [];
}
return sortByNumericValue(response.js.data);
},
}),
/**
* Resource to fetch seasons for VOD items that are actually series (is_series=1)
* Used for Ministra plugin where VOD items can contain series/seasons
*/
vodSeriesSeasonsResource: resource({
params: () => ({
selectedItem: (store as any).selectedItem(),
selectedContentType:
(store as any).selectedContentType?.() ?? 'vod',
}),
loader: async ({
params,
}): Promise<StalkerVodSeriesSeason[]> => {
const item = params.selectedItem;
logger.debug('vodSeriesSeasonsResource loader called', {
item,
isSeries: item?.is_series,
currentPlaylist: (store as any).currentPlaylist(),
});
// Only fetch if item is a VOD series (has is_series flag)
if (
!(store as any).currentPlaylist() ||
params.selectedContentType !== 'vod' ||
!item ||
!item.is_series
) {
logger.debug(
'vodSeriesSeasonsResource skipped - conditions not met'
);
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: item.id,
p: '1',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
if (!response?.js?.data) {
logger.debug(
'vodSeriesSeasonsResource - no response data'
);
return [];
}
logger.debug(
'vodSeriesSeasonsResource response data',
response.js.data
);
// Filter for season items (is_season: true)
const seasons = response.js.data.filter(
(item: StalkerVodSeriesSeason) =>
item.is_season === true
);
logger.debug(
'vodSeriesSeasonsResource filtered seasons',
seasons
);
return sortVodSeriesSeasonsByNumber(seasons);
},
}),
})
),
withComputed((store) => {
const storeAny = store as any;
return {
/** serials */
getSerialSeasonsResource: computed(() =>
storeAny.serialSeasonsResource.value()
),
isSerialSeasonsLoading: computed(() =>
storeAny.serialSeasonsResource.isLoading()
),
/** VOD series (Ministra plugin is_series=1) */
getVodSeriesSeasonsResource: computed(() =>
storeAny.vodSeriesSeasonsResource.value()
),
isVodSeriesSeasonsLoading: computed(() =>
storeAny.vodSeriesSeasonsResource.isLoading()
),
};
}),
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
return {
/**
* Fetch episodes for a VOD series season (Ministra plugin)
* @param videoId The video_id from the season item
* @param seasonId The season id
* @returns Array of episode items
*/
async fetchVodSeriesEpisodes(
videoId: string,
seasonId: string
): Promise<StalkerVodSeriesEpisode[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: videoId,
season_id: seasonId,
p: '1',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
if (!response?.js?.data) {
return [];
}
// Filter for episode items (is_episode: true) and sort by series_number
const episodes = sortEpisodesByNumber(
response.js.data.filter(
(item: StalkerVodSeriesEpisode) =>
item.is_episode === true
)
);
// Store episodes and selected season
patchState(store, {
vodSeriesEpisodes: episodes,
selectedVodSeriesSeasonId: seasonId,
});
return episodes;
},
};
}
)
);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './features';
export * from './utils';
@@ -0,0 +1,3 @@
export * from './stalker-content-mappers';
export * from './stalker-sorting';
@@ -0,0 +1,77 @@
import {
StalkerContentItem,
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
/**
* Convert relative URLs to absolute URLs using the portal base URL
* Handles screenshot_uri and cmd paths that come as relative from the server
*/
export function makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
// Already absolute URL
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
// Parse the base URL to get origin
try {
const url = new URL(baseUrl);
// Ensure the relative path starts with /
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
/**
* Post-process stalker items to convert relative URLs to absolute
*/
export function processItemUrls<T extends StalkerVodSource>(
item: T,
portalUrl: string
): T {
const processed = { ...item };
// Convert screenshot_uri to absolute URL
if (processed.screenshot_uri) {
processed.screenshot_uri = makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
export function toStalkerContentItem(
item: StalkerVodSource,
portalUrl: string
): StalkerContentItem {
const processed = processItemUrls(item, portalUrl);
return {
...processed,
cover: processed.screenshot_uri,
};
}
export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel {
return {
...item,
id: item.id ?? item.stream_id ?? '',
cmd: String(item.cmd ?? ''),
name:
typeof item.name === 'string' ? item.name : undefined,
o_name:
typeof item.o_name === 'string' ? item.o_name : undefined,
logo:
typeof item.logo === 'string' ? item.logo : undefined,
};
}
@@ -0,0 +1,48 @@
import {
StalkerSeason,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
export function extractNumericValue(str: string): number {
const matches = str.match(/\d+/);
if (matches) {
return parseInt(matches[0], 10);
}
return 0;
}
export function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
if (!array) return [];
const key = 'name';
return [...array].sort((a, b) => {
const numericA = extractNumericValue(a[key]);
const numericB = extractNumericValue(b[key]);
return numericA - numericB;
});
}
export function sortVodSeriesSeasonsByNumber(
array: StalkerVodSeriesSeason[]
): StalkerVodSeriesSeason[] {
if (!array) return [];
return [...array].sort((a, b) => {
const numA = Number(a.season_number ?? 0) || 0;
const numB = Number(b.season_number ?? 0) || 0;
return numA - numB;
});
}
/**
* Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2")
*/
export function sortEpisodesByNumber(
episodes: StalkerVodSeriesEpisode[]
): StalkerVodSeriesEpisode[] {
if (!episodes) return [];
return [...episodes].sort((a, b) => {
const numA = Number(a.series_number ?? 0) || 0;
const numB = Number(b.series_number ?? 0) || 0;
return numA - numB;
});
}
@@ -7,6 +7,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { selectActivePlaylist } from 'm3u-state';
import { XtreamApiService } from '../services/xtream-api.service';
import { XtreamAccountInfo } from './account-info.interface';
import { createLogger } from '../../shared/utils/logger';
@Component({
selector: 'app-account-info',
@@ -22,6 +23,7 @@ export class AccountInfoComponent {
}>(MAT_DIALOG_DATA);
private readonly xtreamApiService = inject(XtreamApiService);
private readonly store = inject(Store);
private readonly logger = createLogger('XtreamAccountInfo');
accountInfo: XtreamAccountInfo;
formattedExpDate: string;
@@ -59,7 +61,7 @@ export class AccountInfoComponent {
).toLocaleDateString();
}
} catch (error) {
console.error('Failed to fetch account info:', error);
this.logger.error('Failed to fetch account info', error);
}
}
@@ -70,4 +72,4 @@ export class AccountInfoComponent {
get isTrial(): boolean {
return this.accountInfo?.user_info?.is_trial === '1';
}
}
}
@@ -11,10 +11,12 @@
@if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
/>
}
@@ -3,6 +3,7 @@ import { PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
VodDetailsItem,
StalkerVodDetails,
@@ -16,6 +17,17 @@ import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.compon
import { XTREAM_DATA_SOURCE } from '../data-sources';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { XtreamStore } from '../stores/xtream.store';
import {
buildStalkerSelectedVodItem,
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
toggleStalkerVodFavorite,
} from '../../stalker/stalker-vod.utils';
import { createLogger } from '../../shared/utils/logger';
import { DownloadsService } from '../../services/downloads.service';
@Component({
selector: 'app-category-content-view',
@@ -35,6 +47,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
private readonly logger = createLogger('CategoryContentView');
private readonly store = this.isStalker
? inject(StalkerStore)
: inject(XtreamStore);
@@ -43,6 +56,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
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 limit = this.store.limit;
readonly pageIndex = this.store.page;
@@ -190,6 +206,23 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
return position?.positionSeconds ?? null;
});
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => {
if (!this.isStalker) return undefined;
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
return stalkerStore.currentPlaylist()?._id;
},
() => this.favoritesRefresh.refreshVersion()
);
readonly isSelectedVodFavorite = computed<boolean>(() =>
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
seasons = [];
ngOnInit() {
@@ -279,34 +312,11 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
onItemClick(item: any) {
const selectedItem = {
id: item.id,
cmd: item.cmd,
// For VOD items with embedded series array (Stalker vclub)
series: item.series,
// Preserve has_files for cmd transformation during playback
has_files: item.has_files,
// Flag for VOD items that are actually series (Ministra plugin)
// is_series can be "1" (string) or 1 (number)
// ONLY set this for VOD content type - regular series should use the standard series flow
is_series:
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1),
// Store video_id for season fetching if available
video_id: item.video_id,
info: {
movie_image: item.screenshot_uri,
description: item.description,
name: item.o_name || item.name,
o_name: item.o_name,
director: item.director,
releasedate: item.year,
genre: item.genres_str,
actors: item.actors,
rating_imdb: item.rating_imdb,
rating_kinopoisk: item.rating_kinopoisk,
},
};
const selectedItem = buildStalkerSelectedVodItem(
item,
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1)
);
if (this.isStalker) {
this.store.setSelectedItem(selectedItem);
@@ -333,14 +343,30 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
await this.store.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: any) {
console.debug('Add to favorites', item);
addToFavorites(item: any, onDone?: () => void) {
this.logger.debug('Add to favorites', item);
if (this.isStalker) {
(this.store as InstanceType<typeof StalkerStore>).addToFavorites(
item,
onDone
);
return;
}
this.store.addToFavorites(item);
onDone?.();
}
removeFromFavorites(favoriteId: string) {
console.debug('Remove from favorites', favoriteId);
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.logger.debug('Remove from favorites', favoriteId);
if (this.isStalker) {
(this.store as InstanceType<typeof StalkerStore>).removeFromFavorites(
favoriteId,
onDone
);
return;
}
this.store.removeFromFavorites(favoriteId);
onDone?.();
}
/** Handle play from vod-details component */
@@ -374,19 +400,14 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
if (event.item.type === 'stalker') {
if (event.isFavorite) {
this.addToFavorites({
...event.item.data,
category_id: 'vod',
title: event.item.data.info?.name,
cover: event.item.data.info?.movie_image,
added_at: new Date().toISOString(),
});
} else {
this.removeFromFavorites(event.item.data.id);
}
}
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
},
});
}
/** Handle back from vod-details component */
@@ -394,6 +415,62 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
this.store.setSelectedItem(null);
}
/** Handle download from vod-details component */
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker') return;
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const playlist = stalkerStore.currentPlaylist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
let cmdToUse = item.cmd;
const itemData = item.data as any;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
// Align with playback path: resolve has_files references to /media/file_{id}.mpg
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId = await stalkerStore.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) return;
const numericId =
normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist._id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
// Playlist info for auto-creation (Stalker playlists)
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
}
@@ -14,6 +14,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe } from '@ngx-translate/core';
import { DatabaseService, XCategoryFromDb } from 'services';
import { createLogger } from '../../shared/utils/logger';
export interface CategoryManagementDialogData {
playlistId: string;
@@ -47,6 +48,7 @@ export class CategoryManagementDialogComponent implements OnInit {
MatDialogRef<CategoryManagementDialogComponent>
);
readonly data = inject<CategoryManagementDialogData>(MAT_DIALOG_DATA);
private readonly logger = createLogger('CategoryManagementDialog');
readonly isLoading = signal(true);
readonly isSaving = signal(false);
@@ -95,7 +97,7 @@ export class CategoryManagementDialogComponent implements OnInit {
}))
);
} catch (error) {
console.error('Error loading categories:', error);
this.logger.error('Error loading categories', error);
} finally {
this.isLoading.set(false);
}
@@ -175,7 +177,7 @@ export class CategoryManagementDialogComponent implements OnInit {
this.dialogRef.close(true);
} catch (error) {
console.error('Error saving category visibility:', error);
this.logger.error('Error saving category visibility', error);
inject(MatSnackBar).open(
'Failed to save category visibility',
'Close',
@@ -14,6 +14,7 @@ import {
XtreamContentItem,
XtreamPlaylistData,
} from './xtream-data-source.interface';
import { createLogger } from '../../shared/utils/logger';
/**
* LocalStorage keys for PWA persistence
@@ -33,6 +34,7 @@ const STORAGE_KEYS = {
@Injectable({ providedIn: 'root' })
export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly apiService = inject(XtreamApiService);
private readonly logger = createLogger('PwaXtreamDataSource');
// In-memory cache for the current session
private categoryCache = new Map<string, XtreamCategory[]>();
@@ -151,7 +153,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
hidden: boolean
): Promise<void> {
// Category visibility is not supported in PWA mode
console.warn('Category visibility not supported in PWA mode');
this.logger.warn('Category visibility not supported in PWA mode');
}
// =========================================================================
@@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import groupBy from 'lodash/groupBy';
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
import { XtreamStore } from '../stores/xtream.store';
import { createLogger } from '../../shared/utils/logger';
@Component({
selector: 'app-recently-viewed',
@@ -26,6 +27,7 @@ export class RecentlyViewedComponent {
private activatedRoute = inject(ActivatedRoute);
private router = inject(Router);
private dialogData = inject(MAT_DIALOG_DATA, { optional: true });
private readonly logger = createLogger('XtreamRecentlyViewed');
readonly isGlobal = this.dialogData?.isGlobal ?? false;
readonly recentItems = computed(() =>
@@ -51,7 +53,7 @@ export class RecentlyViewedComponent {
try {
await this.xtreamStore.loadGlobalRecentItems();
} catch (error) {
console.error('Error loading global items:', error);
this.logger.error('Error loading global items', error);
}
}
@@ -28,6 +28,7 @@ import { ContentCardComponent } from '../../shared/components/content-card/conte
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
import { XtreamStore } from '../stores/xtream.store';
import { SearchFilters } from '../stores/features/with-search.feature';
import { createLogger } from '../../shared/utils/logger';
interface SearchResultsData {
isGlobalSearch: boolean;
@@ -55,6 +56,7 @@ export class SearchResultsComponent implements AfterViewInit {
readonly router = inject(Router);
readonly activatedRoute = inject(ActivatedRoute);
readonly databaseService = inject(DatabaseService);
private readonly logger = createLogger('XtreamSearchResults');
/** Search term from store */
readonly searchTerm = this.xtreamStore.searchTerm;
@@ -182,7 +184,7 @@ export class SearchResultsComponent implements AfterViewInit {
this.xtreamStore.setIsSearching(false);
}
} catch (error) {
console.error('Error in global search:', error);
this.logger.error('Error in global search', error);
this.xtreamStore.resetSearchResults();
}
}
@@ -12,6 +12,7 @@ import { StalkerStore } from '../../stalker/stalker.store';
import { ProgressCapsuleComponent } from '../shared/progress-capsule/progress-capsule.component';
import { WatchedBadgeComponent } from '../shared/watched-badge/watched-badge.component';
import { DownloadsService } from '../../services/downloads.service';
import { createLogger } from '../../shared/utils/logger';
type EpisodeViewMode = 'grid' | 'list';
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
@@ -36,6 +37,7 @@ export class SeasonContainerComponent implements OnInit {
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('SeasonContainer');
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>();
@@ -177,7 +179,7 @@ export class SeasonContainerComponent implements OnInit {
event.stopPropagation();
const playlistId = this.getPlaylistId(episode);
if (!playlistId) {
console.warn('[SeasonContainer] Cannot toggle watched: no playlist ID');
this.logger.warn('Cannot toggle watched: no playlist ID');
return;
}
@@ -218,9 +220,11 @@ export class SeasonContainerComponent implements OnInit {
const contentId = this.getEpisodeContentId(episode);
const inProgress = this.xtreamStore.isInProgress(contentId, 'episode');
if (inProgress) {
console.log(
`[SeasonContainer] Episode ${episode.title} (contentId=${contentId}, originalId=${(episode as any).originalId}) is IN PROGRESS`
);
this.logger.debug('Episode in progress', {
title: episode.title,
contentId,
originalId: (episode as any).originalId,
});
}
return inProgress;
}
@@ -354,11 +358,11 @@ export class SeasonContainerComponent implements OnInit {
episode.episode_num // series param for episode number
);
if (!url) {
console.error('[SeasonContainer] Failed to resolve Stalker stream URL');
this.logger.error('Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
console.error('[SeasonContainer] Error resolving Stalker stream URL:', error);
this.logger.error('Error resolving Stalker stream URL', error);
return;
}
@@ -2,6 +2,7 @@ import { inject, Injectable, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { EpgItem } from 'shared-interfaces';
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
import { createLogger } from '../../shared/utils/logger';
interface CacheEntry {
data: EpgItem[];
@@ -17,6 +18,7 @@ interface CacheEntry {
@Injectable({ providedIn: 'root' })
export class EpgQueueService implements OnDestroy {
private readonly apiService = inject(XtreamApiService);
private readonly logger = createLogger('EpgQueueService');
private readonly cache = new Map<number, CacheEntry>();
private queue: number[] = [];
@@ -115,8 +117,8 @@ export class EpgQueueService implements OnDestroy {
});
this.epgResult$.next({ streamId, items });
} catch (error) {
console.error(
`EpgQueueService: failed to load EPG for stream ${streamId}:`,
this.logger.error(
`Failed to load EPG for stream ${streamId}`,
error
);
} finally {
@@ -18,6 +18,7 @@ import {
} from '../../data-sources/xtream-data-source.interface';
import { XtreamCredentials } from '../../services/xtream-api.service';
import { ContentType } from '../../xtream-state';
import { createLogger } from '../../../shared/utils/logger';
/**
* Content state for managing categories and streams
@@ -61,6 +62,7 @@ const initialContentState: ContentState = {
* - Import progress tracking
*/
export function withContent() {
const logger = createLogger('withContent');
return signalStoreFeature(
withState<ContentState>(initialContentState),
@@ -174,7 +176,7 @@ export function withContent() {
isLoadingCategories: false,
});
} catch (error) {
console.error('Error fetching categories:', error);
logger.error('Error fetching categories', error);
patchState(store, { isLoadingCategories: false });
}
},
@@ -234,7 +236,7 @@ export function withContent() {
isLoadingContent: false,
});
} catch (error) {
console.error('Error fetching content:', error);
logger.error('Error fetching content', error);
patchState(store, { isLoadingContent: false });
}
},
@@ -275,10 +277,7 @@ export function withContent() {
);
localStorage.removeItem(restoreKey);
} catch (err) {
console.error(
'Error restoring user data:',
err
);
logger.error('Error restoring user data', err);
}
}
} finally {
@@ -318,7 +317,7 @@ export function withContent() {
serialCategories: series,
});
} catch (error) {
console.error('Error reloading categories:', error);
logger.error('Error reloading categories', error);
}
},
@@ -10,6 +10,7 @@ import {
XtreamApiService,
XtreamCredentials,
} from '../../services/xtream-api.service';
import { createLogger } from '../../../shared/utils/logger';
/**
* EPG state for managing Electronic Program Guide data
@@ -34,6 +35,7 @@ const initialEpgState: EpgState = {
* - Loading channel EPG for preview
*/
export function withEpg() {
const logger = createLogger('withEpg');
return signalStoreFeature(
withState<EpgState>(initialEpgState),
@@ -94,7 +96,7 @@ export function withEpg() {
return epgItems;
} catch (error) {
console.error('Error loading EPG:', error);
logger.error('Error loading EPG', error);
patchState(store, {
epgItems: [],
isLoadingEpg: false,
@@ -119,7 +121,7 @@ export function withEpg() {
1
);
} catch (error) {
console.error('Error loading channel EPG:', error);
logger.error('Error loading channel EPG', error);
return [];
}
},
@@ -17,6 +17,7 @@ import {
XtreamCredentials,
} from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import { createLogger } from '../../../shared/utils/logger';
/**
* Portal state for managing playlist and portal status
@@ -44,6 +45,7 @@ const initialPortalState: PortalState = {
* - Checking portal status (active, inactive, expired, unavailable)
*/
export function withPortal() {
const logger = createLogger('withPortal');
return signalStoreFeature(
withState<PortalState>(initialPortalState),
@@ -98,7 +100,7 @@ export function withPortal() {
}
}
} catch (error) {
console.error('Error fetching playlist:', error);
logger.error('Error fetching playlist', error);
}
},
@@ -140,7 +142,7 @@ export function withPortal() {
patchState(store, { portalStatus: 'inactive' });
}
} catch (error) {
console.error('Error checking portal status:', error);
logger.error('Error checking portal status', error);
patchState(store, { portalStatus: 'unavailable' });
}
},
@@ -10,6 +10,7 @@ import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '../../data-sources/xtream-data-source.interface';
import { createLogger } from '../../../shared/utils/logger';
/**
* Search filters configuration
@@ -58,6 +59,7 @@ const initialSearchState: SearchState = {
* - Global search results from external sources
*/
export function withSearch() {
const logger = createLogger('withSearch');
return signalStoreFeature(
withState<SearchState>(initialSearchState),
@@ -99,7 +101,7 @@ export function withSearch() {
return results;
} catch (error) {
console.error('Error searching content:', error);
logger.error('Error searching content', error);
patchState(store, {
searchResults: [],
isSearching: false,
@@ -28,6 +28,7 @@ import {
withPlayer,
withPlaybackPositions,
} from './features';
import { createLogger } from '../../shared/utils/logger';
/**
* XtreamStore - Facade composing all feature stores.
@@ -80,6 +81,7 @@ export const XtreamStore = signalStore(
withMethods((store) => {
const xtreamApiService = inject(XtreamApiService);
const ngrxStore = inject(Store);
const logger = createLogger('XtreamStore');
const searchContent = (store as any)
.searchContent as (term: string, types: string[], excludeHidden?: boolean) => Promise<unknown>;
@@ -143,7 +145,7 @@ export const XtreamStore = signalStore(
stream_id: params.vodId,
});
}).catch((error: unknown) => {
console.error('Error fetching VOD details:', error);
logger.error('Error fetching VOD details', error);
store.setDetailsError(
error instanceof Error ? error.message : 'Unknown error'
);
@@ -175,7 +177,7 @@ export const XtreamStore = signalStore(
series_id: params.serialId,
});
}).catch((error: unknown) => {
console.error('Error fetching series details:', error);
logger.error('Error fetching series details', error);
store.setDetailsError(
error instanceof Error ? error.message : 'Unknown error'
);
@@ -192,11 +194,11 @@ export const XtreamStore = signalStore(
},
addToFavorites(item: unknown): void {
console.log('Legacy addToFavorites called', item);
logger.debug('Legacy addToFavorites called', item);
},
removeFromFavorites(favoriteId: string): void {
console.log('Legacy removeFromFavorites called', favoriteId);
logger.debug('Legacy removeFromFavorites called', favoriteId);
},
// Alias methods for backward compatibility
@@ -10,6 +10,7 @@ import { DownloadsService } from '../../services/downloads.service';
import { SettingsStore } from '../../services/settings-store.service';
import { XtreamStore } from '../stores/xtream.store';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '../../shared/utils/logger';
/**
* Route-based VOD details container for Xtream.
@@ -39,6 +40,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private readonly xtreamStore = inject(XtreamStore);
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('VodDetailsRoute');
readonly theme = this.settingsStore.theme;
readonly isElectron = this.downloadsService.isAvailable;
@@ -51,9 +53,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly hasPlaybackPosition = computed(() => {
const vodId = this.route.snapshot.params.vodId;
const inProgress = this.xtreamStore.isInProgress(Number(vodId), 'vod');
console.log(
`[VodDetails] hasPlaybackPosition check: vodId=${vodId}, inProgress=${inProgress}`
);
this.logger.debug('hasPlaybackPosition check', {
vodId,
inProgress,
});
return inProgress;
});
@@ -106,7 +109,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
vodItem.movie_data?.stream_id || (vodItem as any).stream_id
);
console.log(`[VodDetails] playVod: Resolved ID=${id} for item`, vodItem);
this.logger.debug('playVod resolved ID', { id, vodItem });
const contentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
@@ -7,8 +7,10 @@ import {
} from '@ngrx/signals';
import { DatabaseService } from 'services';
import { FavoritesService } from './services/favorites.service';
import { createLogger } from '../shared/utils/logger';
export const withFavorites = function () {
const logger = createLogger('withFavorites');
return signalStoreFeature(
withState({
isFavorite: false,
@@ -30,8 +32,8 @@ export const withFavorites = function () {
playlistId
);
if (!content) {
console.error(
'Content not found for xtream ID:',
logger.error(
'Content not found for xtream ID',
xtreamId
);
return false;
@@ -8,6 +8,7 @@ import {
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { DatabaseService } from 'services';
import { createLogger } from '../shared/utils/logger';
export interface RecentlyViewedItem {
id: number;
@@ -22,6 +23,7 @@ export interface RecentlyViewedItem {
}
export const withRecentItems = function () {
const logger = createLogger('withRecentItems');
return signalStoreFeature(
withState({
recentItems: [],
@@ -110,7 +112,7 @@ export const withRecentItems = function () {
recentItems: items || [],
});
} catch (error) {
console.error('Error loading global recent items:', error);
logger.error('Error loading global recent items', error);
patchState(store, { recentItems: [] });
}
},
@@ -119,10 +121,7 @@ export const withRecentItems = function () {
await dbService.clearGlobalRecentlyViewed();
patchState(store, { recentItems: [] });
} catch (error) {
console.error(
'Error clearing global recently viewed:',
error
);
logger.error('Error clearing global recently viewed', error);
}
},
}))
+1 -1
View File
@@ -10,7 +10,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2022",
"typeRoots": ["node_modules/@types"],
"typeRoots": ["../../node_modules/@types"],
"lib": ["es2018", "es2017", "es2016", "es2015", "dom"],
"resolveJsonModule": true,
"strict": false,
+1 -1
View File
@@ -7,7 +7,7 @@
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
+321
View File
@@ -0,0 +1,321 @@
# M3U Playlist Module Architecture
This document describes the M3U playlist module architecture, which handles traditional M3U/M3U8 playlists (as opposed to Xtream Codes or Stalker Portal).
## Overview
The M3U playlist module provides:
- Channel list display with virtual scrolling (90,000+ channels support)
- EPG (Electronic Program Guide) integration
- Favorites management with drag-and-drop reordering
- Channel grouping and search
- Video playback with multiple player backends
## Module Structure
```
┌─────────────────────────────────────────────────────────────────────┐
│ VIDEO PLAYER PAGE │
│ apps/web/src/app/home/video-player/ │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │
│ │ Sidebar │ │ Video Player │ │ EPG List │ │
│ │ │ │ (ArtPlayer/Video.js)│ │ (Right drawer) │ │
│ │ ┌─────────┐ │ │ │ │ │ │
│ │ │Channel │ │ │ │ │ │ │
│ │ │List │ │ │ │ │ │ │
│ │ │Container│ │ │ │ │ │ │
│ │ └─────────┘ │ │ │ │ │ │
│ └─────────────┘ └──────────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ NgRx STORE (m3u-state) │
│ libs/m3u-state/ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Playlist │ │ Channel │ │ EPG │ │Favorites │ │ Filter │ │
│ │ Reducer │ │ Reducer │ │ Reducer │ │ Reducer │ │ Reducer │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## State Management (libs/m3u-state/)
### State Structure
```typescript
interface PlaylistState {
// Active channel being played
active: Channel | undefined;
// All channels from current playlist
channels: Channel[];
// EPG state
epg: {
epgAvailable: boolean;
activeEpgProgram: EpgProgram | undefined;
currentEpgProgram: EpgProgram | undefined;
};
// Playlist metadata (entity adapter)
playlistsMeta: {
ids: string[];
entities: Record<string, PlaylistMeta>;
selectedId: string | undefined;
allPlaylistsLoaded: boolean;
selectedFilters: PlaylistSourceFilter[];
};
}
```
### Actions
| Action Group | Actions | Purpose |
|--------------|---------|---------|
| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD |
| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation |
| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state |
| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management |
| **FilterActions** | `setSelectedFilters` | Playlist type filtering |
### Key Selectors
```typescript
// Channel selectors
selectActive // Current playing channel
selectChannels // All channels array
selectFavorites // Favorite channel URLs
// Playlist selectors
selectAllPlaylistsMeta // All playlists
selectActivePlaylistId // Selected playlist ID
selectCurrentPlaylist // Active playlist object
selectPlaylistTitle // Title with "Global favorites" fallback
// EPG selectors
selectIsEpgAvailable // EPG data available flag
selectCurrentEpgProgram // Current playing program
```
## Channel List Container
**Location**: `libs/ui/components/src/lib/channel-list-container/`
### Component Architecture
```
channel-list-container/
├── channel-list-container.component.ts # Parent - shared state coordinator
├── channel-list-container.component.html
├── channel-list-container.component.scss
│
├── all-channels-tab/ # Virtual scroll + search
│ ├── all-channels-tab.component.ts
│ ├── all-channels-tab.component.html
│ └── all-channels-tab.component.scss
│
├── groups-tab/ # Expansion panels + infinite scroll
│ ├── groups-tab.component.ts
│ ├── groups-tab.component.html
│ └── groups-tab.component.scss
│
├── favorites-tab/ # Drag-drop reordering
│ ├── favorites-tab.component.ts
│ ├── favorites-tab.component.html
│ └── favorites-tab.component.scss
│
└── channel-list-item/ # Individual channel display
├── channel-list-item.component.ts
├── channel-list-item.component.html
└── channel-list-item.component.scss
```
### Data Flow
```
┌──────────────────────────────────────────────────────────────┐
│ ChannelListContainerComponent │
│ (Parent) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Shared State (Signals): │ │
│ │ - channelEpgMap: Map<string, EpgProgram> │ │
│ │ - progressTick: number (30s interval) │ │
│ │ - shouldShowEpg: boolean │ │
│ │ - favoriteIds: Set<string> │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────┼─────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │
│ │ All │ │ Groups │ │ Favorites │ │
│ │Channels │ │ Tab │ │ Tab │ │
│ │ Tab │ │ │ │ │ │
│ └────┬────┘ └────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
│ └───────────────────┴─────────────────────┘ │
│ │ │
│ ▼ │
│ (channelSelected) output │
│ │ │
└──────────────────────────┼───────────────────────────────────┘
▼
Store Dispatch
ChannelActions.setActiveChannel
```
### EnrichedChannel Pattern
For performance optimization, channels are pre-enriched with EPG data:
```typescript
interface EnrichedChannel extends Channel {
epgProgram: EpgProgram | null | undefined;
progressPercentage: number; // Pre-computed by parent
}
```
### Performance Optimizations
| Optimization | Implementation |
|--------------|----------------|
| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels |
| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe |
| **Debounced Search** | 300ms debounce on search input |
| **Global Progress Tick** | Single 30s interval instead of per-item intervals |
| **OnPush Change Detection** | All components use OnPush |
| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time |
| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal |
### Tab Components
#### AllChannelsTabComponent
- **Inputs**: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds`
- **Outputs**: `channelSelected`, `favoriteToggled`
- **Features**: Search with 300ms debounce, virtual scrolling, no-results placeholder
#### GroupsTabComponent
- **Inputs**: Same as AllChannelsTab + `groupedChannels`
- **Outputs**: `channelSelected`, `favoriteToggled`
- **Features**: Expansion panels, infinite scroll with IntersectionObserver, lazy loading
#### FavoritesTabComponent
- **Inputs**: `favorites`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
- **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered`
- **Features**: Drag-and-drop reordering with CDK DragDrop
## EPG Integration
### EpgService (libs/services/)
```typescript
class EpgService {
// Fetch EPG for multiple URLs
fetchEpg(urls: string[]): void;
// Get programs for a channel
getChannelPrograms(channelId: string): void;
// Batch fetch current programs
getCurrentProgramsForChannels(channelIds: string[]): Observable<Map<string, EpgProgram>>;
// Observables
epgAvailable$: Observable<boolean>;
currentEpgPrograms$: Observable<EpgProgram[]>;
}
```
### EPG Components
| Component | Purpose |
|-----------|---------|
| `EpgListComponent` | Timeline view for single channel |
| `EpgListItemComponent` | Individual program in timeline |
| `EpgItemDescriptionComponent` | Program details dialog |
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
## Video Player
**Location**: `apps/web/src/app/home/video-player/`
### Supported Players
- **ArtPlayer** (default) - Modern player with plugins
- **Video.js** - Fallback with HLS support
- **HTML5** - Basic video element
- **Audio** - For radio streams
### Player Features
- Channel navigation (prev/next)
- Favorites toggle
- EPG sidebar
- Multi-EPG modal view
- Channel info overlay
- External player support (MPV, VLC) in Electron
## Interfaces
### Channel Interface
```typescript
interface Channel {
id: string;
url: string;
name: string;
group: { title: string };
tvg: {
id: string; // For EPG matching
name: string;
url: string;
logo: string;
rec: string;
};
epgParams?: string;
timeshift?: string;
catchup?: { type?: string; source?: string; days?: string };
http: {
referrer: string;
'user-agent': string;
origin: string;
};
}
```
### EpgProgram Interface
```typescript
interface EpgProgram {
start: string; // ISO string
stop: string; // ISO string
channel: string; // TVG ID
title: string;
desc: string | null;
category: string | null;
episodeNum?: string | null;
iconUrl?: string | null;
rating?: string | null;
}
```
## Routes
```
/playlists/:id # Video player with playlist
/iptv # Default IPTV route
```
## Adding New Features
### To add a new tab to channel list:
1. Create component in `channel-list-container/new-tab/`
2. Accept inputs: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
3. Emit `channelSelected` output
4. Add to parent template and imports
### To add EPG-related features:
1. Use `EpgService` for data fetching
2. Subscribe to `channelEpgMap` signal for current programs
3. Dispatch `EpgActions` for state updates
### To modify favorites behavior:
1. Dispatch `FavoritesActions.updateFavorites` for toggle
2. Dispatch `FavoritesActions.setFavorites` for reordering
3. Effects automatically persist to database
+245
View File
@@ -0,0 +1,245 @@
# Remote Control Architecture
This document describes the current remote control implementation in IPTVnator, including:
- HTTP API exposed by Electron main process
- IPC bridge between Electron main and Angular renderer
- Feature support and integration points for M3U, Xtream, and Stalker
- Remote web UI structure and behavior
Related architecture docs:
- [Stalker Portal Architecture](./stalker-portal.md)
- [Stalker Portal EPG Architecture](./stalker-epg.md)
## Scope
Remote control is a desktop-only feature that serves a mobile-friendly web app from the Electron backend and routes remote actions into the running renderer.
Current capabilities:
- Channel up / down
- Channel select by number
- Volume commands (implemented in command layer; active support currently in M3U flow)
- Playback status polling (portal, live-state, channel name/number, EPG now, volume capability)
## High-Level Flow
1. User opens remote web UI (`http://<local-ip>:<port>`).
2. Remote web app calls `/api/remote-control/*`.
3. Electron main handles API request and sends IPC to renderer:
- `CHANNEL_CHANGE` for up/down
- `REMOTE_CONTROL_COMMAND` for numeric/volume commands
4. Renderer-specific feature module (M3U/Xtream/Stalker) applies action.
5. Renderer pushes status snapshots back to main via:
- `REMOTE_CONTROL_STATUS_UPDATE`
6. Remote web app polls `/api/remote-control/status` and updates UI.
## Backend (Electron Main)
### HTTP server and static app hosting
- File: `apps/electron-backend/src/app/server/http-server.ts`
- Responsibilities:
- Serves static remote app from:
- dev: `dist/apps/remote-control-web/browser`
- prod: `<appPath>/remote-control-web/browser`
- Routes `/api/remote-control/*` to registered handlers.
- Starts/stops/restarts on settings updates.
### Remote control event module
- File: `apps/electron-backend/src/app/events/remote-control.events.ts`
- Bootstrapped in: `apps/electron-backend/src/main.ts` via `RemoteControlEvents.bootstrapRemoteControlEvents()`
Registered endpoints:
- `POST /api/remote-control/channel/up`
- `POST /api/remote-control/channel/down`
- `POST /api/remote-control/channel/select-number` with `{ number: <int> }`
- `POST /api/remote-control/volume/up`
- `POST /api/remote-control/volume/down`
- `POST /api/remote-control/volume/toggle-mute`
- `GET /api/remote-control/status`
IPC emitted to renderer:
- `CHANNEL_CHANGE` payload: `{ direction: 'up' | 'down' }`
- `REMOTE_CONTROL_COMMAND` payload:
- `{ type: 'channel-select-number', number }`
- `{ type: 'volume-up' | 'volume-down' | 'volume-toggle-mute' }`
Status ingestion from renderer:
- Listens on `REMOTE_CONTROL_STATUS_UPDATE`
- Maintains in-memory `RemoteControlStatus` object returned by `/status`
### Settings integration
- Main handler: `apps/electron-backend/src/app/events/settings.events.ts`
- On `SETTINGS_UPDATE`, reads `remoteControl` and `remoteControlPort`, persists to store, and calls:
- `httpServer.updateSettings(enabled, port)`
## Preload Bridge
- File: `apps/electron-backend/src/app/api/main.preload.ts`
Exposed APIs relevant to remote control:
- `onChannelChange(callback) => unsubscribe`
- `onRemoteControlCommand(callback) => unsubscribe`
- `updateRemoteControlStatus(status) => void`
Type definitions:
- `apps/web/src/typings.d.ts`
- `global.d.ts`
## Renderer Integrations
## Shared helpers
- File: `apps/web/src/app/shared/services/remote-channel-navigation.util.ts`
Functions:
- `getAdjacentChannelItem(...)`: wraps around on boundaries for up/down
- `getChannelItemByNumber(...)`: 1-based number to list item mapping
Used by M3U, Xtream, and Stalker live integrations.
## M3U integration
- File: `apps/web/src/app/home/video-player/video-player.component.ts`
Implemented behavior:
- Subscribes to:
- `onChannelChange` (up/down)
- `onRemoteControlCommand` (number + volume)
- Applies channel up/down by active channel URL over `channels$`
- Applies number select through existing `switchToChannelByNumber(...)`
- Applies volume commands:
- up/down in 0.1 increments
- toggle mute with last non-zero volume restore
- persists to `localStorage`
- Publishes status snapshots via `updateRemoteControlStatus(...)`:
- `portal: 'm3u'`
- `isLiveView: true`
- channel name/number
- EPG now fields
- `supportsVolume: true`, `volume`, `muted`
- Cleans listeners/subscriptions in `ngOnDestroy`.
## Xtream integration (live view)
- File: `apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts`
Implemented behavior:
- Subscribes to:
- `onChannelChange` for up/down
- `onRemoteControlCommand` for number select
- Up/down:
- Uses selected live item `selectedItem().xtream_id`
- Navigates inside `selectItemsFromSelectedCategory()`
- Calls `playLive(nextItem)`
- Number select:
- Maps number to item in current category list
- Calls `playLive(channel)`
- Publishes status via effect:
- `portal: 'xtream'`
- `isLiveView` only when selected content type is `live` and item is selected
- channel name/number + current EPG item
- `supportsVolume: false`
- Cleans listeners in `ngOnDestroy`.
## Stalker integration (ITV live view)
- File: `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
Implemented behavior:
- Subscribes to:
- `onChannelChange` for up/down
- `onRemoteControlCommand` for number select
- Up/down:
- Uses `selectedItem().id`
- Navigates inside `itvChannels()`
- Calls `playChannel(nextItem)`
- Number select:
- Maps number into `itvChannels()`
- Calls `playChannel(channel)`
- Publishes status via effect:
- `portal: 'stalker'`
- `isLiveView` only for selected content type `itv` with active item
- channel name/number + current EPG item
- `supportsVolume: false`
- Cleans listeners in `ngOnDestroy`.
## Remote Web App
### App shell
- App: `apps/remote-control-web/src/app/app.ts`
- Template: `apps/remote-control-web/src/app/app.html`
- Style: `apps/remote-control-web/src/app/app.scss`
- Renders shared library component: `<lib-remote-control />`
### Shared remote UI library
- Component:
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts`
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.html`
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss`
- Service:
- `libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts`
Implemented UI behavior:
- Channel pad (`CH+`, `CH-`)
- Numeric keypad (`0-9`, `DEL`, `CLR`, `OK`)
- Volume controls (`VOL-`, `MUTE/UNMUTE`, `VOL+`)
- Status card (portal, channel name/number, current program)
- Polls `/status` every 2s
- Uses action wrapper to refresh status after command execution
## Settings UI and discoverability
- Files:
- `apps/web/src/app/settings/settings.component.ts`
- `apps/web/src/app/settings/settings.component.html`
- Features:
- Toggle `remoteControl`
- Configure `remoteControlPort`
- Display local URLs and QR codes for remote access
- Local IP list loaded via `getLocalIpAddresses()`
## Feature Matrix (Current)
| Capability | M3U | Xtream Live | Stalker ITV |
|---|---|---|---|
| Channel up/down | Yes | Yes | Yes |
| Number select | Yes | Yes | Yes |
| Status publish | Yes | Yes | Yes |
| Volume command handling | Yes | No | No |
| `supportsVolume` in status | true | false | false |
## Known limitations
- Volume commands are currently no-op in Xtream and Stalker integrations.
- Remote status uses polling from web UI (2s), not push/WebSocket.
- Number-based selection is list-position based (1-based index in active list scope), not global EPG number mapping.
- Remote API currently has no auth/TLS; intended for trusted local networks.
## Operational notes
- UI updates in remote web app require rebuilding `remote-control-web` so Electron serves fresh `dist` assets.
- If stale UI appears, clear browser cache/hard-refresh mobile browser.
## Future extension points
- Add optional auth token for `/api/remote-control/*` endpoints.
- Add WebSocket/SSE status push for lower latency and reduced polling.
- Add cross-portal volume abstraction and capability negotiation.
- Add last-channel, favorites navigation, and search/select commands.
+212
View File
@@ -0,0 +1,212 @@
# Stalker Portal EPG Architecture
This document describes the EPG (Electronic Program Guide) implementation for Stalker/Ministra portal live TV (ITV) streams.
Related architecture docs:
- [Stalker Portal Architecture](./stalker-portal.md)
- [Remote Control Architecture](./remote-control.md)
## Overview
The Stalker ITV live stream layout displays EPG data in the right panel when a live channel is playing. EPG is fetched per-channel using the Stalker `get_short_epg` API action, which returns the current program plus the next ~5 upcoming programs. The response is mapped to the shared `EpgItem` interface and rendered by the reusable `EpgViewComponent`.
## Architecture
```
┌───────────────────────────────────────────────────────────────────────────┐
│ StalkerLiveStreamLayoutComponent │
│ apps/web/src/app/stalker/stalker-live-stream-layout/ │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ Sidebar │ │ Video Player │ │ EPG Panel │ │
│ │ (channels) │ │ (WebPlayerView) │ │ (EpgViewComponent) │ │
│ │ │ │ │ │ │ │
│ │ click ──────┼────┼──► playChannel() ────┼────┼──► loadEpgFor...() │ │
│ └──────────────┘ └──────────────────────┘ └─────────────────────┘ │
│ │ │ │
└──────────────────────────────┼────────────────────────────┼───────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌────────────────────────┐
│ StalkerStore │ │ StalkerStore │
│ fetchLinkToPlay() │ │ fetchChannelEpg() │
└──────────┬───────────┘ └────────────┬───────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────┐
│ Stalker Portal API │
│ action=create_link action=get_short_epg │
└──────────────────────────────────────────────────────┘
```
## Stalker EPG API
### `get_short_epg` (per-channel)
**Request:**
```
GET load.php?type=itv&action=get_short_epg&ch_id={channel_id}&JsHttpRequest=1-xml
```
Uses standard Stalker auth headers (Bearer token + MAC cookie).
**Response:**
```json
{
"js": {
"data": [
{
"id": "123",
"ch_id": "45",
"name": "Program Title",
"descr": "Program description",
"time": "2025-01-15 14:00:00",
"time_to": "2025-01-15 14:30:00",
"duration": "1800",
"start_timestamp": "1736949600",
"stop_timestamp": "1736951400",
"t_time": "14:00",
"t_time_to": "14:30"
}
]
}
}
```
**Notes:**
- If the channel has no `xmltv_id` set on the server, returns an empty array
- Response normalization handles both `{ js: { data: [...] } }` and `{ js: [...] }` formats
- No backend (Electron IPC) changes needed — the generic `stalker.events.ts` handler forwards any `params` to the portal URL
### `get_epg_info` (bulk — reserved for future use)
```
GET load.php?type=itv&action=get_epg_info&period={hours}&JsHttpRequest=1-xml
```
Returns EPG for all channels for a given time period. Currently unused but the enum value `StalkerPortalActions.GetEpgInfo` is defined for future bulk EPG features.
## Data Mapping
### Stalker EPG → `EpgItem` Interface
| Stalker field | `EpgItem` field | Notes |
|--------------------|--------------------|-------|
| `id` | `id` | Converted to string |
| `ch_id` | `channel_id` | Falls back to passed `channelId` |
| `name` | `title` | |
| `descr` | `description` | |
| `time` | `start` | Full datetime string |
| `time_to` | `end`, `stop` | Both set to same value |
| `start_timestamp` | `start_timestamp` | Unix timestamp as string |
| `stop_timestamp` | `stop_timestamp` | Unix timestamp as string |
| _(n/a)_ | `epg_id` | Empty string |
| _(n/a)_ | `lang` | Empty string |
### `EpgItem` Interface
```typescript
// libs/shared/interfaces/src/lib/epg-item.interface.ts
interface EpgItem {
id: string;
epg_id: string;
title: string;
lang: string;
start: string;
end: string;
stop: string;
description: string;
channel_id: string;
start_timestamp: string;
stop_timestamp: string;
}
```
## Implementation Details
### Key Files
| File | Purpose |
|------|---------|
| `libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts` | `GetShortEpg`, `GetEpgInfo` enum values |
| `apps/web/src/app/stalker/stalker.store.ts` | `fetchChannelEpg()` method |
| `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | EPG signals + `loadEpgForChannel()` |
| `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | `<app-epg-view>` integration |
| `libs/ui/shared-portals/src/lib/epg-view/epg-view.component.ts` | Shared EPG display component |
### StalkerStore.fetchChannelEpg()
Location: `apps/web/src/app/stalker/stalker.store.ts` (in `withMethods`)
```typescript
async fetchChannelEpg(channelId: number | string): Promise<EpgItem[]>
```
- Sends `get_short_epg` request with `ch_id` param
- Supports both full Stalker portals (authenticated via `StalkerSessionService`) and simple portals (direct IPC)
- Returns mapped `EpgItem[]` or empty array on failure
- No store state mutation — returns data directly to the component
### StalkerLiveStreamLayoutComponent EPG Integration
The component manages EPG state locally with signals:
```typescript
readonly epgItems = signal<EpgItem[]>([]);
readonly isLoadingEpg = signal(false);
```
**Flow:**
1. User clicks a channel in the sidebar → `playChannel(item)`
2. `fetchLinkToPlay()` gets the stream URL
3. If using embedded player, `streamUrl` is set and `loadEpgForChannel(item.id)` is called
4. `loadEpgForChannel()` sets loading state, calls `stalkerStore.fetchChannelEpg()`, updates `epgItems`
5. Template renders `<app-epg-view [epgItems]="epgItems()">` or a loading spinner
### EpgViewComponent (shared)
Location: `libs/ui/shared-portals/src/lib/epg-view/`
Reusable component shared between Stalker and Xtream live stream layouts:
- **Input:** `epgItems: EpgItem[]`
- Displays program list with time, title, and info button
- Highlights current program with green progress bar
- Handles empty state (shows "EPG not available" message)
- Info button opens `EpgItemDescriptionComponent` dialog with title and description
**Current program detection:**
```typescript
isCurrentProgram(item: EpgItem): boolean {
const now = new Date().getTime();
const start = new Date(item.start).getTime();
const stop = new Date(item.stop ?? item.end).getTime();
return now >= start && now <= stop;
}
```
This works with Stalker's datetime format (`"2025-01-15 14:00:00"`) because `new Date()` parses it correctly.
## Authentication
EPG requests follow the same authentication pattern as all Stalker API calls:
| Portal Type | Auth Method |
|-------------|-------------|
| **Full Stalker** (`isFullStalkerPortal: true`) | `StalkerSessionService.makeAuthenticatedRequest()` — handles token refresh and retry on 401 |
| **Simple Stalker** | Direct IPC via `DataService.sendIpcEvent(STALKER_REQUEST, ...)` — no auth headers |
## Future Enhancements
### Bulk EPG in Channel List Sidebar
Use `get_epg_info` with `period=3` to pre-fetch current program titles for all channels when a category is selected:
- Call `get_epg_info` after category selection
- Build a `Map<channelId, currentProgram>`
- Show current program name below channel title in the sidebar
- Display progress bar per channel item
This is a separate task due to Stalker's lazy-loaded channel pagination model.
### EPG Auto-Refresh
Currently EPG is fetched once per channel selection. A future enhancement could add a timer to refresh EPG data periodically (e.g., every 5 minutes) to keep the "current program" indicator accurate during long viewing sessions.
+156
View File
@@ -0,0 +1,156 @@
# Stalker Portal Architecture
This document describes the Stalker portal implementation in IPTVnator and where each feature is integrated.
## Related Docs
- [Stalker Portal EPG Architecture](./stalker-epg.md)
- [Remote Control Architecture](./remote-control.md)
- [Download Manager](./download-manager.md)
- [Category Management](./category-management.md)
- [Stalker Store API Baseline](./stalker-store-api-baseline.md)
## Scope
Stalker support covers:
- Live TV (`itv`)
- VOD (`vod`)
- Series (`series`)
- VOD-as-series flows (`is_series=1` and embedded `series[]`)
- Favorites and recently viewed collections
- Search
- External player playback (shared Xtream player infrastructure)
- Remote control for live ITV navigation
## Routing Structure
Primary route tree lives in `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker.routes.ts`.
- `/stalker/:id/vod`
- `/stalker/:id/series`
- `/stalker/:id/itv`
- `/stalker/:id/favorites`
- `/stalker/:id/recent`
- `/stalker/:id/search`
- `/stalker/:id/downloads` (shared downloads module from Xtream UI)
## Runtime Architecture
1. Angular Stalker screens call methods/resources in `StalkerStore`.
2. `StalkerStore` builds request params based on selected content type and current view state.
3. Requests go through `DataService.sendIpcEvent(STALKER_REQUEST, ...)` or `StalkerSessionService` (full portal auth).
4. Electron main process handles `STALKER_REQUEST` in `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/events/stalker.events.ts`.
5. Axios calls Stalker `load.php` API with required headers/cookies and returns normalized payloads to renderer.
## Main UI Components
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-main-container.component.ts`
- Category + content layout for `vod` and `series`
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
- ITV live playback, channel navigation, EPG panel integration
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts`
- Season/episode UI for all Stalker series modes
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts`
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts`
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts`
## Store and Data Flow
Stalker store is now feature-composed:
- Facade: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker.store.ts`
- Feature slices: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stores/features/*`
- Shared store utils: `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stores/utils/*`
Important store responsibilities:
- Selected content/category/item state
- Category and paginated content resources
- ITV channel list + pagination
- Regular series seasons resource
- VOD-series (`is_series=1`) seasons + episodes resources
- Playback link creation (`create_link` flow)
- Favorites and recently viewed persistence helpers
## VOD/Series Modes
Stalker has multiple real-world data shapes. The current implementation supports all three:
1. Regular Series (`/series`):
- Seasons come from API resource (`serialSeasonsResource`).
- Episodes are derived from season payload.
2. VOD with Embedded `series[]`:
- Item is opened under VOD, but already contains episodes.
- `StalkerSeriesViewComponent` creates a pseudo-season and renders episodes directly.
3. VOD with `is_series=1` (Ministra plugin behavior):
- Treated as series flow from VOD context.
- Seasons are fetched lazily.
- Episodes are fetched on season select.
- Uses unique generated tracking IDs for episode playback position compatibility.
Core decision logic and normalization are centralized in:
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-vod.utils.ts`
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/models/*.ts`
## Favorites and Recently Viewed
Current implementation is shared via Stalker-specific helpers:
- `createPortalCollectionResource(...)` generic collection loader
- `createPortalFavoritesResource(...)` favorites wrapper
- `createStalkerDetailViewState(...)` unified "open detail" decision
- `toggleStalkerVodFavorite(...)` shared add/remove behavior
- `normalizeStalkerEntityId(...)` and `normalizeStalkerEntityIdAsNumber(...)` for stable ID matching
- `matchesFavoriteById(...)` for cross-shape favorite matching
Where this is used:
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts`
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts`
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts`
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/favorites-button/favorites-button.component.ts`
## Remote Control Integration
Stalker live remote control is implemented in:
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
Supported today:
- Channel up/down
- Numeric channel selection (list-position based)
- Status publish for remote UI (portal/channel/current program)
See full backend and web-remote flow in [Remote Control Architecture](./remote-control.md).
## EPG Integration
EPG in Stalker ITV uses `get_short_epg` and shared `EpgViewComponent`. Full details are documented in [Stalker Portal EPG Architecture](./stalker-epg.md).
## Shared/Reusable Infrastructure
Stalker reuses some Xtream UI infrastructure deliberately:
- Category content rendering route uses Xtream category content component
- Season container for episodes uses shared Xtream season UI component
- Playback position handling for series episodes reuses Xtream store position mechanisms
- Downloads route reuses shared downloads feature
This reduces duplicate UI logic across portal types and keeps compatibility behavior aligned.
## Regression Coverage
Focused regression tests for Stalker VOD mode branching live in:
- `/Users/4gray/Code/iptvnator/apps/web/src/app/stalker/stalker-vod.utils.spec.ts`
Covered scenarios include:
- Embedded `series[]` opens series view state
- `is_series=1` opens lazy series state
- Favorite toggle helper path invokes the expected add/remove flow
@@ -0,0 +1,126 @@
# Stalker Store API Baseline
This is the compatibility baseline for refactoring `apps/web/src/app/stalker/stalker.store.ts`.
Goal: keep this public surface stable while splitting to feature stores.
## Source of Truth
- Store implementation: `apps/web/src/app/stalker/stalker.store.ts`
- Baseline created on current branch state before feature-store extraction.
## Public State Signals
Direct signal properties currently exposed by `signalStore`:
- `selectedContentType: 'vod' | 'itv' | 'series'`
- `selectedCategoryId: string | null | undefined`
- `selectedVodId: string | undefined`
- `selectedSerialId: string | undefined`
- `selectedItvId: string | undefined`
- `limit: number`
- `page: number`
- `searchPhrase: string`
- `currentPlaylist: PlaylistMeta`
- `totalCount: number`
- `selectedItem: StalkerVodSource | null | undefined`
- `vodCategories: StalkerCategoryItem[]`
- `seriesCategories: StalkerCategoryItem[]`
- `itvCategories: StalkerCategoryItem[]`
- `hasMoreChannels: boolean`
- `itvChannels: StalkerItvChannel[]`
- `vodSeriesSeasons: StalkerVodSeriesSeason[]`
- `vodSeriesEpisodes: StalkerVodSeriesEpisode[]`
- `selectedVodSeriesSeasonId: string | undefined`
## Public Computed Selectors
- `getTotalPages: number`
- `getPaginatedContent: StalkerContentItem[] | undefined`
- `isPaginatedContentLoading: boolean`
- `isPaginatedContentFailed: unknown`
- `getSerialSeasonsResource: StalkerSeason[]`
- `isSerialSeasonsLoading: boolean`
- `getVodSeriesSeasonsResource: StalkerVodSeriesSeason[]`
- `isVodSeriesSeasonsLoading: boolean`
- `getCategoryResource: StalkerCategoryItem[]`
- `isCategoryResourceLoading: boolean`
- `isCategoryResourceFailed: unknown`
- `getSelectedCategoryName: string`
## Exposed Resources/Props
These are currently reachable on the store object and used internally by computed selectors:
- `getCategoryResource` (resource)
- `getContentResource` (resource)
- `serialSeasonsResource` (resource)
- `vodSeriesSeasonsResource` (resource)
- `makeStalkerRequest(...)`
During refactor:
- Keep compatibility for external callers that may read these directly.
- If moved/renamed internally, provide facade aliases.
## Public Methods (Compatibility Contract)
- `setSelectedContentType(type: 'vod' | 'itv' | 'series'): void`
- `setSelectedCategory(id: string | number | null): void`
- `setSelectedSerialId(id: string): void`
- `setSelectedVodId(id: string): void`
- `setSelectedItvId(id: string): void`
- `setLimit(limit: number): void`
- `setPage(page: number): void`
- `setCurrentPlaylist(playlist: PlaylistMeta | undefined): Promise<void>`
- `setSelectedItem(selectedItem: StalkerVodSource | null | undefined): void`
- `clearSelectedItem(): void`
- `setCategories(type: 'vod' | 'series' | 'itv', categories: StalkerCategoryItem[]): void`
- `resetCategories(): void`
- `setItvChannels(channels: StalkerItvChannel[]): void`
- `setSearchPhrase(phrase: string): void`
- `fetchVodSeriesEpisodes(videoId: string, seasonId: string): Promise<StalkerVodSeriesEpisode[]>`
- `getSelectedCategory(): { id: string | number; name: string; type: 'vod' | 'itv' | 'series' }`
- `fetchLinkToPlay(portalUrl: string, macAddress: string, cmd: string, series?: number): Promise<string>`
- `getExpireDate(): Promise<string>`
- `addToFavorites(item: any, onDone?: () => void): void`
- `removeFromFavorites(favoriteId: string, onDone?: () => void): void`
- `fetchMovieFileId(movieId: string): Promise<string | null>`
- `createLinkToPlayVod(cmd?: string, title?: string, thumbnail?: string, episodeNum?: number, episodeId?: number, startTime?: number): Promise<void>`
- `addToRecentlyViewed(item: any): void`
- `removeFromRecentlyViewed(itemId: number, onComplete?: () => void): void`
- `fetchChannelEpg(channelId: number | string, size?: number): Promise<EpgItem[]>`
## Current Consumers (Observed)
Top observed store API usage in app code:
- `currentPlaylist` (16 references)
- `setSelectedContentType` (9)
- `selectedItem` (9)
- `setSelectedItem` (7)
- `setSelectedCategory` (6)
- `createLinkToPlayVod` (6)
- `removeFromFavorites` (5)
- `setPage` (4)
- `addToFavorites` (4)
- `fetchChannelEpg` (3)
- plus lower-frequency calls for paging/resources/series/recent.
Consumer directories sampled:
- `apps/web/src/app/stalker/**`
- `apps/web/src/app/xtream-tauri/**`
- `apps/web/src/app/shared/**`
## Invariants to Preserve During Refactor
- Selection IDs (`selectedVodId`, `selectedSerialId`, `selectedItvId`) are synchronized in `setSelectedItem`.
- `setSelectedCategory(...)` resets `page` to `0`.
- `createLinkToPlayVod(...)` continues to:
- support episode playback metadata
- append recently viewed
- preserve external player payload shape
- Full-portal auth path continues through `StalkerSessionService`.
- Non-auth/simple path continues through `DataService.sendIpcEvent(STALKER_REQUEST, ...)`.
- Resource-driven loading signals preserve existing names.
+52 -10
View File
@@ -221,13 +221,27 @@ export class PlaylistsService {
this.dbService.update(DbStores.Playlists, {
...portal,
favorites: portal.favorites?.filter(
(i) =>
(i as Partial<XtreamItem>).stream_id !==
favoriteId &&
(i as Partial<XtreamSerieItem>).series_id !==
favoriteId &&
(i as Partial<{ movie_id: string }>).movie_id !==
favoriteId
(i) => {
const expectedId = String(favoriteId);
const streamId = String(
(i as Partial<XtreamItem>).stream_id ?? ''
);
const seriesId = String(
(i as Partial<XtreamSerieItem>).series_id ?? ''
);
const movieId = String(
(i as Partial<{ movie_id: string }>).movie_id ??
''
);
const itemId = String((i as any).id ?? '');
return (
streamId !== expectedId &&
seriesId !== expectedId &&
movieId !== expectedId &&
itemId !== expectedId
);
}
),
})
)
@@ -397,13 +411,41 @@ export class PlaylistsService {
}
removeFromPortalRecentlyViewed(portalId: string, id: string | number) {
const normalizePortalItemId = (value: unknown): string => {
const raw = String(value ?? '').trim();
if (!raw) return '';
return raw.includes(':') ? raw.split(':')[0] : raw;
};
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const expectedId = String(id);
const expectedNormalized = normalizePortalItemId(id);
return this.dbService.update(DbStores.Playlists, {
...portal,
recentlyViewed: portal.recentlyViewed?.filter((i: any) => {
const itemId = String(i?.id ?? '');
const itemNormalized = normalizePortalItemId(itemId);
return (
itemId !== expectedId &&
itemNormalized !== expectedNormalized
);
}),
});
})
);
}
clearPortalRecentlyViewed(portalId: string) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) =>
this.dbService.update(DbStores.Playlists, {
...portal,
recentlyViewed: portal.recentlyViewed?.filter(
(i) => i.id !== id
),
recentlyViewed: [],
})
)
);