mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #771 from 4gray/refactor/stalker-portals
Refactor: Stalker portal store
This commit is contained in:
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)) {
|
||||
|
||||
+15
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
-25
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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()"
|
||||
/>
|
||||
}
|
||||
|
||||
+122
-45
@@ -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?.();
|
||||
}
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user