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:
4gray committed 2026-02-14 11:47:36 +01:00
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);
}
}
@@ -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;
}
+169 -82
View File
@@ -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,
@@ -11,6 +11,7 @@
@if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
@@ -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 */
+21 -7
View File
@@ -221,13 +221,27 @@ export class PlaylistsService {
this.dbService.update(DbStores.Playlists, {
...portal,
favorites: portal.favorites?.filter(
(i) =>
(i as Partial<XtreamItem>).stream_id !==
favoriteId &&
(i as Partial<XtreamSerieItem>).series_id !==
favoriteId &&
(i as Partial<{ movie_id: string }>).movie_id !==
favoriteId
(i) => {
const expectedId = String(favoriteId);
const streamId = String(
(i as Partial<XtreamItem>).stream_id ?? ''
);
const seriesId = String(
(i as Partial<XtreamSerieItem>).series_id ?? ''
);
const movieId = String(
(i as Partial<{ movie_id: string }>).movie_id ??
''
);
const itemId = String((i as any).id ?? '');
return (
streamId !== expectedId &&
seriesId !== expectedId &&
movieId !== expectedId &&
itemId !== expectedId
);
}
),
})
)