mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
refactor(stalker): add VOD series adapters and tests
- Implemented `stalker-series.adapters.ts` for mapping VOD series seasons and episodes. - Added utility functions for handling VOD series items and mapping to view models. - Created unit tests for the new adapter functions in `stalker-series.adapters.spec.ts`. - Updated `stalker.store.ts` to manage VOD series state and handle favorites. - Enhanced `category-content-view` component to support favorite functionality for VOD items. - Refactored favorite management in `playlists.service.ts` to ensure proper ID handling.
This commit is contained in:
1 parent
29552b6e7d
commit
f0be21486d
15 files changed
+967
-625
No files matched your search
@@ -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,16 +1,27 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { rxResource } from '@angular/core/rxjs-interop';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistsService } from 'services';
|
||||
import {
|
||||
VodDetailsItem,
|
||||
StalkerVodDetails,
|
||||
createStalkerVodItem,
|
||||
} from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import {
|
||||
StalkerSelectedVodItem,
|
||||
StalkerVodSource,
|
||||
} from '../models';
|
||||
import {
|
||||
createPortalCollectionResource,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
toggleStalkerVodFavorite,
|
||||
clearStalkerDetailViewState,
|
||||
createStalkerDetailViewState,
|
||||
createRefreshTrigger,
|
||||
normalizeStalkerVodDetailsItem,
|
||||
} from '../stalker-vod.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recently-viewed',
|
||||
@@ -36,28 +47,29 @@ import { StalkerStore } from '../stalker.store';
|
||||
],
|
||||
})
|
||||
export class RecentlyViewedComponent {
|
||||
private static isCategoryType(
|
||||
value: string
|
||||
): value is 'vod' | 'series' | 'itv' {
|
||||
return value === 'vod' || value === 'series' || value === 'itv';
|
||||
}
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly refreshTimestamp = signal<number>(Date.now());
|
||||
private readonly collectionRefresh = createRefreshTrigger();
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly logger = createLogger('StalkerRecentlyViewed');
|
||||
|
||||
itemDetails: any = null;
|
||||
itemDetails: (StalkerVodSource & { category_id?: string }) | null = null;
|
||||
vodDetailsItem: VodDetailsItem | null = null;
|
||||
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
readonly allFavorites = rxResource({
|
||||
params: () => {
|
||||
const portalId = this.stalkerStore.currentPlaylist()?._id;
|
||||
if (!portalId) return undefined;
|
||||
return {
|
||||
portalId,
|
||||
refreshTimestamp: this.refreshTimestamp(),
|
||||
};
|
||||
},
|
||||
stream: ({ params }) =>
|
||||
this.playlistService.getPortalRecentlyViewed(params.portalId),
|
||||
});
|
||||
readonly allFavorites = createPortalCollectionResource(
|
||||
this.playlistService,
|
||||
() => this.stalkerStore.currentPlaylist()?._id,
|
||||
() => this.collectionRefresh.refreshVersion(),
|
||||
(playlistService, portalId) =>
|
||||
playlistService.getPortalRecentlyViewed(portalId)
|
||||
);
|
||||
|
||||
readonly categories = computed(() => [
|
||||
{
|
||||
@@ -119,18 +131,23 @@ export class RecentlyViewedComponent {
|
||||
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
|
||||
);
|
||||
|
||||
removeFromRecentlyViewed(item: any) {
|
||||
this.stalkerStore.removeFromRecentlyViewed(item.id);
|
||||
setTimeout(() => {
|
||||
this.refreshTimestamp.set(Date.now());
|
||||
}, 100);
|
||||
removeFromRecentlyViewed(item: Pick<StalkerVodSource, 'id'>) {
|
||||
const id = normalizeStalkerEntityIdAsNumber(item.id);
|
||||
if (id === null) return;
|
||||
this.stalkerStore.removeFromRecentlyViewed(id, () => {
|
||||
this.collectionRefresh.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: any) {
|
||||
setCategoryId(categoryId: string) {
|
||||
this.selectedCategoryId.set(categoryId);
|
||||
}
|
||||
|
||||
openItem(item: any) {
|
||||
openItem(item: StalkerVodSource & { category_id: string }) {
|
||||
if (!RecentlyViewedComponent.isCategoryType(item.category_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stalkerStore.setSelectedContentType(item.category_id);
|
||||
switch (item.category_id) {
|
||||
case 'itv':
|
||||
@@ -139,24 +156,16 @@ export class RecentlyViewedComponent {
|
||||
case 'vod':
|
||||
// Normalize the item to ensure is_series flag is properly set
|
||||
const normalizedItem = this.normalizeRecentItem(item);
|
||||
this.itemDetails = normalizedItem;
|
||||
this.stalkerStore.setSelectedItem(normalizedItem);
|
||||
|
||||
// Check if this VOD item is actually a series (Ministra is_series=1)
|
||||
const isVodSeries =
|
||||
normalizedItem.is_series === true ||
|
||||
normalizedItem.series?.length > 0;
|
||||
|
||||
if (isVodSeries) {
|
||||
// VOD series - will be rendered as series view
|
||||
this.vodDetailsItem = null;
|
||||
} else {
|
||||
// Regular VOD - create VodDetailsItem for the component
|
||||
this.vodDetailsItem = createStalkerVodItem(
|
||||
normalizedItem as StalkerVodDetails,
|
||||
this.currentPlaylist()?._id ?? ''
|
||||
);
|
||||
}
|
||||
const detailViewState = createStalkerDetailViewState(
|
||||
normalizedItem,
|
||||
this.currentPlaylist()?._id ?? ''
|
||||
);
|
||||
this.itemDetails = {
|
||||
...(detailViewState.itemDetails ?? normalizedItem),
|
||||
category_id: 'vod',
|
||||
};
|
||||
this.vodDetailsItem = detailViewState.vodDetailsItem;
|
||||
this.stalkerStore.setSelectedItem(detailViewState.itemDetails);
|
||||
break;
|
||||
case 'series':
|
||||
this.itemDetails = item;
|
||||
@@ -180,25 +189,18 @@ export class RecentlyViewedComponent {
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
if (event.item.type === 'stalker') {
|
||||
if (event.isFavorite) {
|
||||
this.addToFavorites({
|
||||
...event.item.data,
|
||||
category_id: 'vod',
|
||||
title: event.item.data.info?.name,
|
||||
cover: event.item.data.info?.movie_image,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
this.removeFromFavorites(event.item.data.id);
|
||||
}
|
||||
}
|
||||
toggleStalkerVodFavorite(event, {
|
||||
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
|
||||
removeFromFavorites: (favoriteId, onDone) =>
|
||||
this.removeFromFavorites(favoriteId, onDone),
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
onVodBack(): void {
|
||||
this.itemDetails = null;
|
||||
this.vodDetailsItem = null;
|
||||
const cleared = clearStalkerDetailViewState();
|
||||
this.itemDetails = cleared.itemDetails;
|
||||
this.vodDetailsItem = cleared.vodDetailsItem;
|
||||
}
|
||||
|
||||
async createLinkToPlayVodItv(
|
||||
@@ -206,44 +208,27 @@ export class RecentlyViewedComponent {
|
||||
title?: string,
|
||||
thumbnail?: string
|
||||
) {
|
||||
console.debug('Create link to play VOD/ITV', cmd, title, thumbnail);
|
||||
this.logger.debug('Create link to play VOD/ITV', {
|
||||
cmd,
|
||||
title,
|
||||
thumbnail,
|
||||
});
|
||||
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
addToFavorites(item: any) {
|
||||
this.stalkerStore.addToFavorites(item);
|
||||
addToFavorites(item: Record<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';
|
||||
|
||||
// Ensure info object exists
|
||||
const info = item.info || {
|
||||
name: item.o_name || item.name || item.title || 'Unknown',
|
||||
o_name: item.o_name,
|
||||
movie_image: item.cover || item.screenshot_uri || item.logo,
|
||||
description: item.description,
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
info: info,
|
||||
is_series: normalizedIsSeries ? true : undefined,
|
||||
// Preserve video_id for season fetching
|
||||
video_id: item.video_id,
|
||||
};
|
||||
private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem {
|
||||
return normalizeStalkerVodDetailsItem(item);
|
||||
}
|
||||
}
|
||||
@@ -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,27 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { rxResource } from '@angular/core/rxjs-interop';
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistsService } from 'services';
|
||||
import {
|
||||
VodDetailsItem,
|
||||
StalkerVodDetails,
|
||||
createStalkerVodItem,
|
||||
} from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import {
|
||||
StalkerFavoriteItem,
|
||||
} from '../models';
|
||||
import {
|
||||
clearStalkerDetailViewState,
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
createStalkerDetailViewState,
|
||||
normalizeStalkerEntityId,
|
||||
NormalizedStalkerFavoriteItem,
|
||||
normalizeStalkerFavoriteItem,
|
||||
isSelectedStalkerVodFavorite,
|
||||
} from '../stalker-vod.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-favorites',
|
||||
@@ -37,27 +48,22 @@ import { StalkerStore } from '../stalker.store';
|
||||
})
|
||||
export class StalkerFavoritesComponent {
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly refreshTimestamp = signal<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 +125,31 @@ export class StalkerFavoritesComponent {
|
||||
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
|
||||
);
|
||||
|
||||
removeFromFavorites(item: any) {
|
||||
this.stalkerStore.removeFromFavorites(item.id);
|
||||
setTimeout(() => {
|
||||
this.refreshTimestamp.set(Date.now());
|
||||
}, 100);
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.allFavorites.value();
|
||||
this.syncSelectedVodFavorite();
|
||||
});
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: any) {
|
||||
removeFromFavorites(item: Pick<StalkerFavoriteItem, 'id'>, onDone?: () => void) {
|
||||
this.stalkerStore.removeFromFavorites(
|
||||
normalizeStalkerEntityId(item.id),
|
||||
() => {
|
||||
this.favoritesRefresh.refresh();
|
||||
this.syncSelectedVodFavorite();
|
||||
onDone?.();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: string) {
|
||||
this.selectedCategoryId.set(categoryId);
|
||||
}
|
||||
|
||||
openItem(item: any) {
|
||||
console.debug('Open item', item);
|
||||
const normalizedItem = this.normalizeFavoriteItem(item);
|
||||
openItem(item: StalkerFavoriteItem) {
|
||||
this.logger.debug('Open item', item);
|
||||
const normalizedItem = normalizeStalkerFavoriteItem(item);
|
||||
|
||||
switch (item.category_id) {
|
||||
case 'itv':
|
||||
@@ -144,25 +161,16 @@ export class StalkerFavoritesComponent {
|
||||
this.stalkerStore.setSelectedItem(normalizedItem.details);
|
||||
this.stalkerStore.setSelectedContentType('vod');
|
||||
|
||||
// Check if this VOD item is actually a series (Ministra is_series=1)
|
||||
const isVodSeries =
|
||||
normalizedItem.details?.is_series === true ||
|
||||
normalizedItem.details?.is_series === 1 ||
|
||||
normalizedItem.details?.is_series === '1' ||
|
||||
item.is_series === true ||
|
||||
item.is_series === 1 ||
|
||||
item.is_series === '1';
|
||||
|
||||
if (isVodSeries || normalizedItem.details?.series?.length > 0) {
|
||||
// VOD series - will be rendered as series view
|
||||
this.vodDetailsItem = null;
|
||||
} else {
|
||||
// Regular VOD - create VodDetailsItem for the component
|
||||
this.vodDetailsItem = createStalkerVodItem(
|
||||
normalizedItem.details as StalkerVodDetails,
|
||||
this.currentPlaylist()?._id ?? ''
|
||||
);
|
||||
}
|
||||
const detailViewState = createStalkerDetailViewState(
|
||||
normalizedItem.details,
|
||||
this.currentPlaylist()?._id ?? ''
|
||||
);
|
||||
this.itemDetails = {
|
||||
...normalizedItem,
|
||||
details: detailViewState.itemDetails,
|
||||
};
|
||||
this.vodDetailsItem = detailViewState.vodDetailsItem;
|
||||
this.syncSelectedVodFavorite();
|
||||
break;
|
||||
case 'series':
|
||||
this.itemDetails = normalizedItem;
|
||||
@@ -189,59 +197,36 @@ export class StalkerFavoritesComponent {
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
if (!event.isFavorite && event.item.type === 'stalker') {
|
||||
this.removeFromFavorites({ id: event.item.data.id });
|
||||
this.isSelectedVodFavorite.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
onVodBack(): void {
|
||||
this.itemDetails = null;
|
||||
this.vodDetailsItem = null;
|
||||
this.resetDetailsView(true);
|
||||
}
|
||||
|
||||
private normalizeFavoriteItem(item: any): any {
|
||||
if (item.details && item.details.info) {
|
||||
return item;
|
||||
onSeriesBack(): void {
|
||||
this.resetDetailsView(true);
|
||||
}
|
||||
|
||||
private resetDetailsView(refreshList: boolean): void {
|
||||
const cleared = clearStalkerDetailViewState();
|
||||
this.itemDetails = null;
|
||||
this.vodDetailsItem = cleared.vodDetailsItem;
|
||||
this.isSelectedVodFavorite.set(false);
|
||||
if (refreshList) {
|
||||
this.favoritesRefresh.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const source = item.details || item;
|
||||
const info = source.info || {
|
||||
name: item.o_name || item.name || item.title || 'Unknown',
|
||||
o_name: item.o_name,
|
||||
movie_image: item.cover || item.screenshot_uri || item.logo,
|
||||
description: item.description,
|
||||
releasedate: item.releasedate || item.year,
|
||||
genre: item.genre || item.genres_str,
|
||||
duration: item.duration,
|
||||
rating_imdb: item.rating_imdb || item.rating,
|
||||
country: item.country,
|
||||
actors: item.actors,
|
||||
director: item.director,
|
||||
backdrop_path:
|
||||
item.backdrop_path ||
|
||||
[item.cover, item.screenshot_uri].filter(Boolean),
|
||||
};
|
||||
|
||||
// Normalize is_series to boolean true (can be 1, "1", or true)
|
||||
const rawIsSeries =
|
||||
item.is_series || source.is_series;
|
||||
const normalizedIsSeries =
|
||||
rawIsSeries === true ||
|
||||
rawIsSeries === 1 ||
|
||||
rawIsSeries === '1';
|
||||
|
||||
return {
|
||||
...item,
|
||||
details: {
|
||||
...source,
|
||||
info: info,
|
||||
cmd: item.cmd || source.cmd,
|
||||
id: item.stream_id || item.id || source.id,
|
||||
series: item.series || source.series,
|
||||
is_series: normalizedIsSeries ? true : undefined,
|
||||
// Preserve video_id for season fetching
|
||||
video_id: item.video_id || source.video_id,
|
||||
},
|
||||
};
|
||||
private syncSelectedVodFavorite(): void {
|
||||
this.isSelectedVodFavorite.set(
|
||||
isSelectedStalkerVodFavorite(
|
||||
this.vodDetailsItem,
|
||||
this.allFavorites.value() ?? []
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async createLinkToPlayVodItv(
|
||||
@@ -249,7 +234,11 @@ export class StalkerFavoritesComponent {
|
||||
title?: string,
|
||||
thumbnail?: string
|
||||
) {
|
||||
console.debug('Create link to play VOD/ITV', cmd, title, thumbnail);
|
||||
this.logger.debug('Create link to play VOD/ITV', {
|
||||
cmd,
|
||||
title,
|
||||
thumbnail,
|
||||
});
|
||||
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
}
|
||||
+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,31 +51,27 @@ 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'
|
||||
);
|
||||
return isVodSeriesItem(this.displayItem());
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -101,30 +88,10 @@ export class StalkerSeriesViewComponent {
|
||||
constructor() {
|
||||
// Effect to load VOD series seasons when a VOD series item is selected
|
||||
effect(() => {
|
||||
const item = this.displayItem();
|
||||
const isSeries =
|
||||
item?.is_series === true ||
|
||||
item?.is_series === 1 ||
|
||||
item?.is_series === '1';
|
||||
if (isSeries) {
|
||||
if (this.isVodSeries()) {
|
||||
// Get seasons from the resource
|
||||
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
|
||||
if (seasons && seasons.length > 0) {
|
||||
// Map seasons to our VodSeriesSeason structure
|
||||
const mappedSeasons: VodSeriesSeason[] = seasons.map(
|
||||
(season: any) => ({
|
||||
id: season.id,
|
||||
video_id: season.video_id,
|
||||
name:
|
||||
season.name || `Season ${season.season_number}`,
|
||||
season_number: season.season_number,
|
||||
episodes: [],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
})
|
||||
);
|
||||
this.vodSeriesSeasons.set(mappedSeasons);
|
||||
}
|
||||
this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons));
|
||||
} else {
|
||||
this.vodSeriesSeasons.set([]);
|
||||
}
|
||||
@@ -136,8 +103,13 @@ export class StalkerSeriesViewComponent {
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
if (item && playlist?._id) {
|
||||
const seriesId = Number(item.id);
|
||||
console.log(
|
||||
`[StalkerSeriesView] Loading positions for series: id=${item.id}, seriesId=${seriesId}, is_series=${item.is_series}`
|
||||
this.logger.debug(
|
||||
'Loading positions for series',
|
||||
{
|
||||
id: item.id,
|
||||
seriesId,
|
||||
isSeries: item.is_series,
|
||||
}
|
||||
);
|
||||
if (!isNaN(seriesId)) {
|
||||
// Load playback positions from the XtreamStore (works for any playlist type)
|
||||
@@ -151,133 +123,38 @@ export class StalkerSeriesViewComponent {
|
||||
* For VOD with embedded series, we create a single "season" with the episodes
|
||||
* For regular series, we use the API-fetched seasons
|
||||
*/
|
||||
readonly regularSeasons = computed<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 +167,10 @@ export class StalkerSeriesViewComponent {
|
||||
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
const season = seasons.find(
|
||||
(s) => (s.season_number || s.name || s.id) == seasonKey
|
||||
(s) => getVodSeriesSeasonKey(s) === seasonKey
|
||||
);
|
||||
|
||||
if (season && (!season.episodes || season.episodes.length === 0)) {
|
||||
if (season && season.episodes.length === 0) {
|
||||
this.loadEpisodesForSeason(season);
|
||||
}
|
||||
}
|
||||
@@ -301,7 +178,7 @@ export class StalkerSeriesViewComponent {
|
||||
/**
|
||||
* Loads episodes for a specific VOD season
|
||||
*/
|
||||
async loadEpisodesForSeason(season: VodSeriesSeason) {
|
||||
async loadEpisodesForSeason(season: VodSeriesSeasonVm) {
|
||||
// Set loading state in local signal
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
const index = seasons.findIndex((s) => s.id === season.id);
|
||||
@@ -329,10 +206,7 @@ export class StalkerSeriesViewComponent {
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[StalkerSeriesViewComponent] Failed to load episodes:',
|
||||
error
|
||||
);
|
||||
this.logger.error('Failed to load episodes', error);
|
||||
const newSeasons = [...this.vodSeriesSeasons()];
|
||||
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
|
||||
if (newIndex !== -1) {
|
||||
@@ -351,20 +225,22 @@ export class StalkerSeriesViewComponent {
|
||||
isCurrentSeasonLoading(seasonKey: string): boolean {
|
||||
if (!this.isVodSeries()) return false;
|
||||
const season = this.vodSeriesSeasons().find(
|
||||
(s) => (s.season_number || s.name || s.id) == seasonKey
|
||||
(s) => getVodSeriesSeasonKey(s) === seasonKey
|
||||
);
|
||||
return season?.isLoading || false;
|
||||
return season?.isLoading ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles episode click from the container
|
||||
*/
|
||||
onEpisodeClicked(episode: XtreamSerieEpisode) {
|
||||
if ((episode as any).custom_sid === 'vod-series') {
|
||||
const mappedEpisode = episode as StalkerMappedEpisode;
|
||||
|
||||
if (mappedEpisode.custom_sid === 'vod-series') {
|
||||
// It's a VOD series episode (is_series=1 mode)
|
||||
// Use originalId for playback URL, but use generated id for tracking
|
||||
this.playVodSeriesEpisode({
|
||||
originalId: (episode as any).originalId, // For constructing playback URL
|
||||
originalId: mappedEpisode.originalId, // For constructing playback URL
|
||||
trackingId: episode.id, // The generated unique ID for position tracking
|
||||
name: episode.title,
|
||||
series_number: episode.episode_num,
|
||||
@@ -375,7 +251,7 @@ export class StalkerSeriesViewComponent {
|
||||
const trackingId = Number(episode.id);
|
||||
this.playEpisodeClicked(
|
||||
episode.episode_num,
|
||||
(episode as any).originalCmd,
|
||||
mappedEpisode.originalCmd,
|
||||
trackingId
|
||||
);
|
||||
}
|
||||
@@ -384,11 +260,15 @@ export class StalkerSeriesViewComponent {
|
||||
/**
|
||||
* Play episode - handles regular series and vclub mode
|
||||
*/
|
||||
playEpisodeClicked(episodeNum: any, cmd?: string, trackingId?: number) {
|
||||
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
|
||||
const item = this.displayItem();
|
||||
console.log(
|
||||
`[StalkerSeriesView] playEpisodeClicked: episodeNum=${episodeNum}, cmd=${cmd}, trackingId=${trackingId}, seriesId=${item.id}`
|
||||
);
|
||||
if (!item) return;
|
||||
this.logger.debug('playEpisodeClicked', {
|
||||
episodeNum,
|
||||
cmd,
|
||||
trackingId,
|
||||
seriesId: item.id,
|
||||
});
|
||||
|
||||
// Get playback position from XtreamStore if available
|
||||
const position = trackingId
|
||||
@@ -409,17 +289,26 @@ export class StalkerSeriesViewComponent {
|
||||
/**
|
||||
* Play VOD series episode
|
||||
*/
|
||||
playVodSeriesEpisode(episode: any) {
|
||||
playVodSeriesEpisode(episode: {
|
||||
originalId?: string;
|
||||
trackingId: string;
|
||||
name: string;
|
||||
series_number: number;
|
||||
}) {
|
||||
const item = this.displayItem();
|
||||
if (!item) return;
|
||||
// Use originalId for playback URL (this is what the Stalker API expects)
|
||||
const cmd = `/media/file_${episode.originalId}.mpg`;
|
||||
const cmd = `/media/file_${episode.originalId ?? ''}.mpg`;
|
||||
const episodeName = episode.name || `Episode ${episode.series_number}`;
|
||||
// Use trackingId (generated unique ID) for playback position tracking
|
||||
const trackingId = Number(episode.trackingId);
|
||||
|
||||
console.log(
|
||||
`[StalkerSeriesView] playVodSeriesEpisode: originalId=${episode.originalId}, trackingId=${trackingId}, seriesId=${item.id}, episodeName=${episodeName}`
|
||||
);
|
||||
this.logger.debug('playVodSeriesEpisode', {
|
||||
originalId: episode.originalId,
|
||||
trackingId,
|
||||
seriesId: item.id,
|
||||
episodeName,
|
||||
});
|
||||
|
||||
// Get playback position from XtreamStore if available
|
||||
const position = this.xtreamStore
|
||||
@@ -437,24 +326,13 @@ export class StalkerSeriesViewComponent {
|
||||
);
|
||||
}
|
||||
|
||||
addToFavorites(item: any) {
|
||||
const displayItem = this.displayItem();
|
||||
this.stalkerStore.addToFavorites({
|
||||
...item,
|
||||
title: displayItem.info.name,
|
||||
cover: displayItem.info.movie_image,
|
||||
series_id: displayItem.id,
|
||||
added_at: new Date().toISOString(),
|
||||
category_id: 'series',
|
||||
});
|
||||
}
|
||||
|
||||
removeFromFavorites(serialId: string) {
|
||||
this.stalkerStore.removeFromFavorites(serialId);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.backClicked.emit();
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
}
|
||||
}
|
||||
|
||||
toSeriesId(id: string): number {
|
||||
const parsed = Number(id);
|
||||
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;
|
||||
}
|
||||
@@ -19,35 +19,43 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import { PlayerService } from '../services/player.service';
|
||||
import { ContentType } from '../xtream/content-type.enum';
|
||||
import { StalkerSeason } from './models/stalker-season.interface';
|
||||
import {
|
||||
StalkerCategoryItem,
|
||||
StalkerContentItem,
|
||||
StalkerItvChannel,
|
||||
StalkerSeason,
|
||||
StalkerVodSeriesEpisode,
|
||||
StalkerVodSeriesSeason,
|
||||
StalkerVodSource,
|
||||
} from './models';
|
||||
import { StalkerContentTypes } from './stalker-content-types';
|
||||
|
||||
interface StalkerCategoryItem {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
}
|
||||
import { createLogger } from '../shared/utils/logger';
|
||||
import {
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
} from './stalker-vod.utils';
|
||||
|
||||
interface StalkerState {
|
||||
selectedContentType: 'vod' | 'itv' | 'series';
|
||||
selectedCategoryId: string;
|
||||
selectedVodId: string;
|
||||
selectedSerialId: string;
|
||||
selectedItvId: string;
|
||||
selectedCategoryId: string | null | undefined;
|
||||
selectedVodId: string | undefined;
|
||||
selectedSerialId: string | undefined;
|
||||
selectedItvId: string | undefined;
|
||||
limit: number;
|
||||
page: number;
|
||||
searchPhrase: string;
|
||||
currentPlaylist: PlaylistMeta;
|
||||
totalCount: number;
|
||||
selectedItem: any;
|
||||
selectedItem: StalkerVodSource | null | undefined;
|
||||
vodCategories: StalkerCategoryItem[];
|
||||
seriesCategories: StalkerCategoryItem[];
|
||||
itvCategories: StalkerCategoryItem[];
|
||||
hasMoreChannels: boolean;
|
||||
itvChannels: any[];
|
||||
itvChannels: StalkerItvChannel[];
|
||||
/** For VOD items that are actually series (Ministra plugin is_series=1) */
|
||||
vodSeriesSeasons: any[];
|
||||
vodSeriesEpisodes: any[];
|
||||
selectedVodSeriesSeasonId: string;
|
||||
vodSeriesSeasons: StalkerVodSeriesSeason[];
|
||||
vodSeriesEpisodes: StalkerVodSeriesEpisode[];
|
||||
selectedVodSeriesSeasonId: string | undefined;
|
||||
}
|
||||
|
||||
const initialState: StalkerState = {
|
||||
@@ -72,6 +80,8 @@ const initialState: StalkerState = {
|
||||
selectedVodSeriesSeasonId: undefined,
|
||||
};
|
||||
|
||||
const logger = createLogger('StalkerStore');
|
||||
|
||||
function extractNumericValue(str: string) {
|
||||
const matches = str.match(/\d+/);
|
||||
if (matches) {
|
||||
@@ -90,14 +100,27 @@ function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
|
||||
});
|
||||
}
|
||||
|
||||
function sortVodSeriesSeasonsByNumber(
|
||||
array: StalkerVodSeriesSeason[]
|
||||
): StalkerVodSeriesSeason[] {
|
||||
if (!array) return [];
|
||||
return [...array].sort((a, b) => {
|
||||
const numA = Number(a.season_number ?? 0) || 0;
|
||||
const numB = Number(b.season_number ?? 0) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2")
|
||||
*/
|
||||
function sortEpisodesByNumber(episodes: any[]): any[] {
|
||||
function sortEpisodesByNumber(
|
||||
episodes: StalkerVodSeriesEpisode[]
|
||||
): StalkerVodSeriesEpisode[] {
|
||||
if (!episodes) return [];
|
||||
return episodes.sort((a, b) => {
|
||||
const numA = parseInt(a.series_number, 10) || 0;
|
||||
const numB = parseInt(b.series_number, 10) || 0;
|
||||
const numA = Number(a.series_number ?? 0) || 0;
|
||||
const numB = Number(b.series_number ?? 0) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
}
|
||||
@@ -131,7 +154,10 @@ function makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
|
||||
/**
|
||||
* Post-process stalker items to convert relative URLs to absolute
|
||||
*/
|
||||
function processItemUrls(item: any, portalUrl: string): any {
|
||||
function processItemUrls<T extends StalkerVodSource>(
|
||||
item: T,
|
||||
portalUrl: string
|
||||
): T {
|
||||
const processed = { ...item };
|
||||
|
||||
// Convert screenshot_uri to absolute URL
|
||||
@@ -145,6 +171,31 @@ function processItemUrls(item: any, portalUrl: string): any {
|
||||
return processed;
|
||||
}
|
||||
|
||||
function toStalkerContentItem(
|
||||
item: StalkerVodSource,
|
||||
portalUrl: string
|
||||
): StalkerContentItem {
|
||||
const processed = processItemUrls(item, portalUrl);
|
||||
return {
|
||||
...processed,
|
||||
cover: processed.screenshot_uri,
|
||||
};
|
||||
}
|
||||
|
||||
function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel {
|
||||
return {
|
||||
...item,
|
||||
id: item.id ?? item.stream_id ?? '',
|
||||
cmd: String(item.cmd ?? ''),
|
||||
name:
|
||||
typeof item.name === 'string' ? item.name : undefined,
|
||||
o_name:
|
||||
typeof item.o_name === 'string' ? item.o_name : undefined,
|
||||
logo:
|
||||
typeof item.logo === 'string' ? item.logo : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const StalkerStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState(initialState),
|
||||
@@ -173,7 +224,7 @@ export const StalkerStore = signalStore(
|
||||
serialNumber = (playlist as Playlist)
|
||||
.stalkerSerialNumber;
|
||||
} catch (error) {
|
||||
console.error('Failed to get stalker token:', error);
|
||||
logger.error('Failed to get stalker token', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +242,7 @@ export const StalkerStore = signalStore(
|
||||
action: StalkerPortalActions.GetCategories,
|
||||
currentPlaylist: store.currentPlaylist(),
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
loader: async ({ params }): Promise<StalkerCategoryItem[]> => {
|
||||
if (params.currentPlaylist === undefined) return;
|
||||
|
||||
switch (params.contentType) {
|
||||
@@ -244,17 +295,14 @@ export const StalkerStore = signalStore(
|
||||
|
||||
// Guard: ensure response has expected structure
|
||||
if (!response?.js || !Array.isArray(response.js)) {
|
||||
console.warn(
|
||||
'[StalkerStore] Invalid categories response:',
|
||||
response
|
||||
);
|
||||
logger.warn('Invalid categories response', response);
|
||||
return [];
|
||||
}
|
||||
|
||||
const categories = response.js
|
||||
.map((item) => ({
|
||||
.map((item): StalkerCategoryItem => ({
|
||||
category_name: item.title,
|
||||
category_id: item.id,
|
||||
category_id: String(item.id),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.category_name.localeCompare(b.category_name)
|
||||
@@ -273,7 +321,9 @@ export const StalkerStore = signalStore(
|
||||
search: store.searchPhrase(),
|
||||
pageIndex: store.page() + 1,
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
loader: async ({
|
||||
params,
|
||||
}): Promise<StalkerContentItem[] | undefined> => {
|
||||
if (
|
||||
!params.category ||
|
||||
params.category === null ||
|
||||
@@ -340,10 +390,7 @@ export const StalkerStore = signalStore(
|
||||
|
||||
// Guard: ensure response has expected structure
|
||||
if (!response?.js?.data) {
|
||||
console.warn(
|
||||
'[StalkerStore] Invalid response structure:',
|
||||
response
|
||||
);
|
||||
logger.warn('Invalid response structure', response);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -352,24 +399,21 @@ export const StalkerStore = signalStore(
|
||||
});
|
||||
|
||||
const portalUrl = currentPlaylist().portalUrl;
|
||||
const newItems = response.js.data.map((item) => {
|
||||
// Post-process to convert relative URLs to absolute
|
||||
const processed = processItemUrls(item, portalUrl);
|
||||
return {
|
||||
...processed,
|
||||
cover: processed.screenshot_uri,
|
||||
};
|
||||
});
|
||||
const newItems = response.js.data.map(
|
||||
(item: StalkerVodSource) =>
|
||||
toStalkerContentItem(item, portalUrl)
|
||||
);
|
||||
|
||||
if (store.selectedContentType() === 'itv') {
|
||||
const channels = newItems.map(toStalkerItvChannel);
|
||||
// Check if we're loading the first page or loading more
|
||||
if (params.pageIndex === 1) {
|
||||
patchState(store, { itvChannels: newItems });
|
||||
patchState(store, { itvChannels: channels });
|
||||
} else {
|
||||
patchState(store, {
|
||||
itvChannels: [
|
||||
...store.itvChannels(),
|
||||
...newItems,
|
||||
...channels,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -424,10 +468,7 @@ export const StalkerStore = signalStore(
|
||||
|
||||
// Guard: ensure response has expected structure
|
||||
if (!response?.js?.data) {
|
||||
console.warn(
|
||||
'[StalkerStore] Invalid seasons response:',
|
||||
response
|
||||
);
|
||||
logger.warn('Invalid seasons response', response);
|
||||
return [];
|
||||
}
|
||||
return sortByNumericValue(response.js.data);
|
||||
@@ -441,17 +482,24 @@ export const StalkerStore = signalStore(
|
||||
params: () => ({
|
||||
selectedItem: store.selectedItem(),
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
loader: async ({
|
||||
params,
|
||||
}): Promise<StalkerVodSeriesSeason[]> => {
|
||||
const item = params.selectedItem;
|
||||
// Debug logging
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource loader called');
|
||||
console.log('[StalkerStore] item:', item);
|
||||
console.log('[StalkerStore] item?.is_series:', item?.is_series);
|
||||
console.log('[StalkerStore] currentPlaylist:', store.currentPlaylist());
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource loader called',
|
||||
{
|
||||
item,
|
||||
isSeries: item?.is_series,
|
||||
currentPlaylist: store.currentPlaylist(),
|
||||
}
|
||||
);
|
||||
|
||||
// Only fetch if item is a VOD series (has is_series flag)
|
||||
if (!store.currentPlaylist() || !item || !item.is_series) {
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource - early return, conditions not met');
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource skipped - conditions not met'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -482,18 +530,27 @@ export const StalkerStore = signalStore(
|
||||
}
|
||||
|
||||
if (!response?.js?.data) {
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource - no response data');
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource - no response data'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource - response.js.data:', response.js.data);
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource response data',
|
||||
response.js.data
|
||||
);
|
||||
|
||||
// Filter for season items (is_season: true)
|
||||
const seasons = response.js.data.filter(
|
||||
(item: any) => item.is_season === true
|
||||
(item: StalkerVodSeriesSeason) =>
|
||||
item.is_season === true
|
||||
);
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource - filtered seasons:', seasons);
|
||||
return sortByNumericValue(seasons);
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource filtered seasons',
|
||||
seasons
|
||||
);
|
||||
return sortVodSeriesSeasonsByNumber(seasons);
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -616,19 +673,28 @@ export const StalkerStore = signalStore(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error syncing Stalker playlist to SQLite:',
|
||||
logger.error(
|
||||
'Error syncing Stalker playlist to SQLite',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
setSelectedItem(selectedItem: any) {
|
||||
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
|
||||
// TODO: check the item type and proper property either selectedVodId or serialsId etc
|
||||
const selectedIdRaw =
|
||||
selectedItem?.id !== undefined &&
|
||||
selectedItem?.id !== null
|
||||
? selectedItem.id
|
||||
: undefined;
|
||||
const selectedId =
|
||||
selectedIdRaw !== undefined
|
||||
? normalizeStalkerEntityId(selectedIdRaw)
|
||||
: undefined;
|
||||
patchState(store, {
|
||||
selectedVodId: selectedItem?.id ?? undefined,
|
||||
selectedSerialId: selectedItem?.id ?? undefined,
|
||||
selectedItvId: selectedItem?.id ?? undefined,
|
||||
selectedVodId: selectedId,
|
||||
selectedSerialId: selectedId,
|
||||
selectedItvId: selectedId,
|
||||
selectedItem,
|
||||
});
|
||||
},
|
||||
@@ -640,7 +706,10 @@ export const StalkerStore = signalStore(
|
||||
selectedItem: undefined,
|
||||
});
|
||||
},
|
||||
setCategories(type: 'vod' | 'series' | 'itv', categories: any[]) {
|
||||
setCategories(
|
||||
type: 'vod' | 'series' | 'itv',
|
||||
categories: StalkerCategoryItem[]
|
||||
) {
|
||||
if (type === 'vod') {
|
||||
patchState(store, { vodCategories: categories });
|
||||
} else if (type === 'series') {
|
||||
@@ -656,7 +725,7 @@ export const StalkerStore = signalStore(
|
||||
itvCategories: [],
|
||||
});
|
||||
},
|
||||
setItvChannels(channels: any[]) {
|
||||
setItvChannels(channels: StalkerItvChannel[]) {
|
||||
patchState(store, { itvChannels: channels });
|
||||
},
|
||||
setSearchPhrase(phrase: string) {
|
||||
@@ -671,7 +740,7 @@ export const StalkerStore = signalStore(
|
||||
async fetchVodSeriesEpisodes(
|
||||
videoId: string,
|
||||
seasonId: string
|
||||
): Promise<any[]> {
|
||||
): Promise<StalkerVodSeriesEpisode[]> {
|
||||
const playlist = store.currentPlaylist() as Playlist;
|
||||
if (!playlist) return [];
|
||||
|
||||
@@ -704,7 +773,8 @@ export const StalkerStore = signalStore(
|
||||
// Filter for episode items (is_episode: true) and sort by series_number
|
||||
const episodes = sortEpisodesByNumber(
|
||||
response.js.data.filter(
|
||||
(item: any) => item.is_episode === true
|
||||
(item: StalkerVodSeriesEpisode) =>
|
||||
item.is_episode === true
|
||||
)
|
||||
);
|
||||
|
||||
@@ -788,7 +858,7 @@ export const StalkerStore = signalStore(
|
||||
// Check for server-side errors
|
||||
if (response.js?.error) {
|
||||
const errorMsg = response.js.error;
|
||||
console.error('[StalkerStore] Server error:', errorMsg);
|
||||
logger.error('Server error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -888,11 +958,11 @@ export const StalkerStore = signalStore(
|
||||
|
||||
return 'Unknown';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch expire date:', error);
|
||||
logger.error('Failed to fetch expire date', error);
|
||||
return 'Error fetching data';
|
||||
}
|
||||
},
|
||||
addToFavorites(item: any) {
|
||||
addToFavorites(item: any, onDone?: () => void) {
|
||||
playlistService
|
||||
.addPortalFavorite(this.currentPlaylist()?._id, {
|
||||
...item,
|
||||
@@ -908,9 +978,10 @@ export const StalkerStore = signalStore(
|
||||
duration: 1000,
|
||||
}
|
||||
);
|
||||
onDone?.();
|
||||
});
|
||||
},
|
||||
removeFromFavorites(favoriteId: string) {
|
||||
removeFromFavorites(favoriteId: string, onDone?: () => void) {
|
||||
playlistService
|
||||
.removeFromPortalFavorites(
|
||||
this.currentPlaylist()?._id,
|
||||
@@ -924,6 +995,7 @@ export const StalkerStore = signalStore(
|
||||
duration: 1000,
|
||||
}
|
||||
);
|
||||
onDone?.();
|
||||
});
|
||||
},
|
||||
/**
|
||||
@@ -985,6 +1057,10 @@ export const StalkerStore = signalStore(
|
||||
const item = this.selectedItem();
|
||||
let cmdToUse = cmd ?? item?.cmd;
|
||||
|
||||
if (!cmdToUse) {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
|
||||
// For items with has_files and relative path, we need to fetch the file id first
|
||||
if (
|
||||
item?.has_files !== undefined &&
|
||||
@@ -993,7 +1069,9 @@ export const StalkerStore = signalStore(
|
||||
cmdToUse.includes('/media/') &&
|
||||
!cmdToUse.includes('/media/file_')
|
||||
) {
|
||||
const fileId = await this.fetchMovieFileId(item.id);
|
||||
const fileId = await this.fetchMovieFileId(
|
||||
normalizeStalkerEntityId(item.id)
|
||||
);
|
||||
if (fileId) {
|
||||
cmdToUse = `/media/file_${fileId}.mpg`;
|
||||
}
|
||||
@@ -1007,19 +1085,24 @@ export const StalkerStore = signalStore(
|
||||
);
|
||||
this.addToRecentlyViewed({
|
||||
...item,
|
||||
id: item.id,
|
||||
id: item?.id,
|
||||
cmd: cmd,
|
||||
cover: thumbnail,
|
||||
title,
|
||||
});
|
||||
const playlist = this.currentPlaylist();
|
||||
const isEpisode = episodeNum !== undefined || episodeId !== undefined;
|
||||
const selectedItemId =
|
||||
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
|
||||
const contentInfo = {
|
||||
playlistId: playlist._id,
|
||||
// For episodes, use episodeId if provided, otherwise fall back to item.id
|
||||
contentXtreamId: isEpisode && episodeId ? episodeId : Number(item.id),
|
||||
contentXtreamId:
|
||||
isEpisode && episodeId
|
||||
? episodeId
|
||||
: selectedItemId,
|
||||
contentType: isEpisode ? 'episode' : 'vod',
|
||||
seriesXtreamId: isEpisode ? Number(item.id) : undefined,
|
||||
seriesXtreamId: isEpisode ? selectedItemId : undefined,
|
||||
};
|
||||
|
||||
playerService.openPlayer(
|
||||
@@ -1035,10 +1118,7 @@ export const StalkerStore = signalStore(
|
||||
startTime
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[StalkerStore] Failed to get playback URL:',
|
||||
error
|
||||
);
|
||||
logger.error('Failed to get playback URL', error);
|
||||
const errorMessage =
|
||||
error?.message === 'nothing_to_play'
|
||||
? translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
|
||||
@@ -1055,13 +1135,20 @@ export const StalkerStore = signalStore(
|
||||
})
|
||||
.subscribe();
|
||||
},
|
||||
removeFromRecentlyViewed(itemId: number) {
|
||||
removeFromRecentlyViewed(itemId: number, onComplete?: () => void) {
|
||||
playlistService
|
||||
.removeFromPortalRecentlyViewed(
|
||||
this.currentPlaylist()?._id,
|
||||
itemId
|
||||
)
|
||||
.subscribe();
|
||||
.subscribe({
|
||||
next: () => onComplete?.(),
|
||||
error: (error) =>
|
||||
logger.error(
|
||||
'Failed to remove item from recently viewed',
|
||||
error
|
||||
),
|
||||
});
|
||||
},
|
||||
async fetchChannelEpg(
|
||||
channelId: number | string,
|
||||
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
@if (vodDetailsItem()) {
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem()!"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
[playbackPosition]="selectedVodPlaybackPosition()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(resumeClicked)="onVodResume($event)"
|
||||
|
||||
+62
-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,14 @@ 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,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '../../stalker/stalker-vod.utils';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-content-view',
|
||||
@@ -35,6 +44,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 +53,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
|
||||
readonly contentType = this.store.selectedContentType;
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly favoritesRefresh = createRefreshTrigger();
|
||||
|
||||
readonly limit = this.store.limit;
|
||||
readonly pageIndex = this.store.page;
|
||||
@@ -190,6 +202,23 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
return position?.positionSeconds ?? null;
|
||||
});
|
||||
|
||||
readonly portalFavorites = createPortalFavoritesResource(
|
||||
this.playlistService,
|
||||
() => {
|
||||
if (!this.isStalker) return undefined;
|
||||
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
|
||||
return stalkerStore.currentPlaylist()?._id;
|
||||
},
|
||||
() => this.favoritesRefresh.refreshVersion()
|
||||
);
|
||||
|
||||
readonly isSelectedVodFavorite = computed<boolean>(() =>
|
||||
isSelectedStalkerVodFavorite(
|
||||
this.vodDetailsItem(),
|
||||
this.portalFavorites.value() ?? []
|
||||
)
|
||||
);
|
||||
|
||||
seasons = [];
|
||||
|
||||
ngOnInit() {
|
||||
@@ -279,34 +308,11 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onItemClick(item: any) {
|
||||
const selectedItem = {
|
||||
id: item.id,
|
||||
cmd: item.cmd,
|
||||
// For VOD items with embedded series array (Stalker vclub)
|
||||
series: item.series,
|
||||
// Preserve has_files for cmd transformation during playback
|
||||
has_files: item.has_files,
|
||||
// Flag for VOD items that are actually series (Ministra plugin)
|
||||
// is_series can be "1" (string) or 1 (number)
|
||||
// ONLY set this for VOD content type - regular series should use the standard series flow
|
||||
is_series:
|
||||
this.contentType() === 'vod' &&
|
||||
(item.is_series === '1' || item.is_series === 1),
|
||||
// Store video_id for season fetching if available
|
||||
video_id: item.video_id,
|
||||
info: {
|
||||
movie_image: item.screenshot_uri,
|
||||
description: item.description,
|
||||
name: item.o_name || item.name,
|
||||
o_name: item.o_name,
|
||||
director: item.director,
|
||||
releasedate: item.year,
|
||||
genre: item.genres_str,
|
||||
actors: item.actors,
|
||||
rating_imdb: item.rating_imdb,
|
||||
rating_kinopoisk: item.rating_kinopoisk,
|
||||
},
|
||||
};
|
||||
const selectedItem = buildStalkerSelectedVodItem(
|
||||
item,
|
||||
this.contentType() === 'vod' &&
|
||||
(item.is_series === '1' || item.is_series === 1)
|
||||
);
|
||||
|
||||
if (this.isStalker) {
|
||||
this.store.setSelectedItem(selectedItem);
|
||||
@@ -333,14 +339,30 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
await this.store.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
addToFavorites(item: any) {
|
||||
console.debug('Add to favorites', item);
|
||||
addToFavorites(item: any, onDone?: () => void) {
|
||||
this.logger.debug('Add to favorites', item);
|
||||
if (this.isStalker) {
|
||||
(this.store as InstanceType<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 +396,14 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}): void {
|
||||
if (event.item.type === 'stalker') {
|
||||
if (event.isFavorite) {
|
||||
this.addToFavorites({
|
||||
...event.item.data,
|
||||
category_id: 'vod',
|
||||
title: event.item.data.info?.name,
|
||||
cover: event.item.data.info?.movie_image,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
this.removeFromFavorites(event.item.data.id);
|
||||
}
|
||||
}
|
||||
toggleStalkerVodFavorite(event, {
|
||||
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
|
||||
removeFromFavorites: (favoriteId, onDone) =>
|
||||
this.removeFromFavorites(favoriteId, onDone),
|
||||
onComplete: () => {
|
||||
this.favoritesRefresh.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user