Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'portal-stalker-data-access',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/portal/stalker/data-access',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "portal-stalker-data-access",
|
||||
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/portal/stalker/data-access/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:stalker", "type:data-access"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/portal/stalker/data-access/jest.config.ts",
|
||||
"tsConfig": "libs/portal/stalker/data-access/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './lib/models';
|
||||
export * from './lib/stores';
|
||||
export * from './lib/stalker-content-types';
|
||||
export * from './lib/stalker-live-playback.utils';
|
||||
export * from './lib/stalker-series.adapters';
|
||||
export * from './lib/stalker-session.service';
|
||||
export * from './lib/stalker-vod.utils';
|
||||
export * from './lib/stalker.store';
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './stalker-favorite-item.interface';
|
||||
export * from './stalker-category-item.interface';
|
||||
export * from './stalker-content-item.interface';
|
||||
export * from './stalker-itv-channel.interface';
|
||||
export * from './stalker-season.interface';
|
||||
export * from './stalker-serial-details.interface';
|
||||
export * from './stalker-vod-details.interface';
|
||||
export * from './stalker-vod-series.interface';
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface StalkerCategoryItem {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StalkerVodSource } from './stalker-favorite-item.interface';
|
||||
|
||||
/**
|
||||
* Generic content item returned by Stalker category content endpoints.
|
||||
* Used for VOD, series, and ITV category lists.
|
||||
*/
|
||||
export interface StalkerContentItem extends StalkerVodSource {
|
||||
cover?: string;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
StalkerVodDetails,
|
||||
StalkerVodInfo,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
export type StalkerSeriesFlag = true | 1 | '1';
|
||||
|
||||
/**
|
||||
* Loose source shape coming from Stalker responses / favorites payloads.
|
||||
* Keep optional to model heterogeneous portal responses.
|
||||
*/
|
||||
export interface StalkerVodSource {
|
||||
id?: string | number;
|
||||
stream_id?: string | number;
|
||||
movie_id?: string | number;
|
||||
series_id?: string | number;
|
||||
cmd?: string;
|
||||
series?: unknown[];
|
||||
has_files?: number;
|
||||
is_series?: boolean | number | string | null | undefined;
|
||||
video_id?: string;
|
||||
category_id?: string;
|
||||
title?: string;
|
||||
cover?: string;
|
||||
logo?: string;
|
||||
screenshot_uri?: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
description?: string;
|
||||
actors?: string;
|
||||
director?: string;
|
||||
releasedate?: string;
|
||||
year?: string;
|
||||
genre?: string;
|
||||
genres_str?: string;
|
||||
rating_imdb?: string | number;
|
||||
rating_kinopoisk?: string | number;
|
||||
info?: Partial<StalkerVodInfo> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized VOD item used by Stalker detail/favorites flows.
|
||||
*/
|
||||
export interface StalkerSelectedVodItem extends StalkerVodDetails {
|
||||
series?: unknown[];
|
||||
has_files?: number;
|
||||
is_series?: true;
|
||||
video_id?: string;
|
||||
category_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorite item persisted for Stalker portal.
|
||||
*/
|
||||
export interface StalkerFavoriteItem {
|
||||
id?: string | number;
|
||||
stream_id?: string | number;
|
||||
movie_id?: string | number;
|
||||
series_id?: string | number;
|
||||
category_id?: string;
|
||||
cmd?: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
logo?: string;
|
||||
details?: StalkerSelectedVodItem;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StalkerVodSource } from './stalker-favorite-item.interface';
|
||||
|
||||
/**
|
||||
* ITV/live channel shape used by Stalker live TV flow.
|
||||
* Stalker channel payloads are heterogeneous, so this extends the shared source shape.
|
||||
*/
|
||||
export interface StalkerItvChannel extends StalkerVodSource {
|
||||
id: string | number;
|
||||
cmd: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
logo?: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface StalkerSeason {
|
||||
id: string;
|
||||
series: string[];
|
||||
name: string;
|
||||
cmd: string;
|
||||
|
||||
description: string;
|
||||
director: string;
|
||||
actors: string;
|
||||
year: string;
|
||||
genres_str: string;
|
||||
age: string;
|
||||
rating_imdb: string;
|
||||
rating_kinopoisk: string;
|
||||
screenshot_uri: string;
|
||||
added: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @deprecated Import from 'shared-interfaces' instead.
|
||||
* This re-export exists for backward compatibility.
|
||||
*/
|
||||
export { StalkerSerialDetails } from 'shared-interfaces';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @deprecated Import from 'shared-interfaces' instead.
|
||||
* This re-export exists for backward compatibility.
|
||||
*/
|
||||
export {
|
||||
StalkerVodDetails,
|
||||
StalkerVodInfo,
|
||||
} from 'shared-interfaces';
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Season item returned by Stalker VOD-series (Ministra is_series=1) endpoints.
|
||||
*/
|
||||
export interface StalkerVodSeriesSeason {
|
||||
id: string;
|
||||
video_id: string;
|
||||
season_number?: string;
|
||||
name?: string;
|
||||
is_season?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode item returned by Stalker VOD-series season endpoints.
|
||||
*/
|
||||
export interface StalkerVodSeriesEpisode {
|
||||
id: string;
|
||||
series_number?: number;
|
||||
episode_num?: number;
|
||||
name?: string;
|
||||
cover?: string;
|
||||
description?: string;
|
||||
duration?: number | string;
|
||||
is_episode?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { StalkerPortalActions } from 'shared-interfaces';
|
||||
|
||||
export const StalkerContentTypes = {
|
||||
stb: {
|
||||
doAuth: 'do_auth',
|
||||
handshake: 'handshake',
|
||||
},
|
||||
itv: {
|
||||
title: 'Live streams',
|
||||
getContentAction: StalkerPortalActions.GetOrderedList,
|
||||
getCategoryAction: StalkerPortalActions.GetGenres,
|
||||
getLink: StalkerPortalActions.CreateLink,
|
||||
},
|
||||
vod: {
|
||||
title: 'VOD streams',
|
||||
getContentAction: StalkerPortalActions.GetOrderedList,
|
||||
getCategoryAction: StalkerPortalActions.GetCategories,
|
||||
getLink: StalkerPortalActions.CreateLink,
|
||||
},
|
||||
series: {
|
||||
title: 'Series',
|
||||
getContentAction: StalkerPortalActions.GetOrderedList,
|
||||
getCategoryAction: StalkerPortalActions.GetCategories,
|
||||
getLink: StalkerPortalActions.CreateLink,
|
||||
},
|
||||
/* radio: {
|
||||
title: 'Radio',
|
||||
getContentAction: StalkerPortalActions.GetOrderedList,
|
||||
getCategoryAction: StalkerPortalActions.GetOrderedList,
|
||||
getLink: StalkerPortalActions.CreateLink,
|
||||
}, */
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
export const STALKER_MAG_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
export const STALKER_STREAM_USER_AGENT = 'KSPlayer';
|
||||
|
||||
export function getStalkerPortalOrigin(
|
||||
playlist: PlaylistMeta | undefined | null
|
||||
): string | undefined {
|
||||
const portalUrl = playlist?.portalUrl;
|
||||
if (!portalUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(portalUrl).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCrossOriginStalkerStream(
|
||||
playlist: PlaylistMeta | undefined | null,
|
||||
streamUrl?: string
|
||||
): boolean {
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
if (!portalOrigin || !streamUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(streamUrl).origin !== portalOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStalkerExternalPlaybackHeaders(
|
||||
playlist: PlaylistMeta | undefined | null,
|
||||
token?: string | null,
|
||||
streamUrl?: string
|
||||
): Record<string, string> {
|
||||
if (!playlist?.macAddress) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (isCrossOriginStalkerStream(playlist, streamUrl)) {
|
||||
return {
|
||||
'User-Agent': STALKER_STREAM_USER_AGENT,
|
||||
Accept: '*/*',
|
||||
Range: 'bytes=0-',
|
||||
Connection: 'keep-alive',
|
||||
'Icy-MetaData': '1',
|
||||
};
|
||||
}
|
||||
|
||||
const cookieParts = [
|
||||
`mac=${playlist.macAddress}`,
|
||||
'stb_lang=en_US@rg=dezzzz',
|
||||
'timezone=Europe/Berlin',
|
||||
];
|
||||
|
||||
if (playlist.stalkerSerialNumber) {
|
||||
cookieParts.push(
|
||||
`__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1`
|
||||
);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: cookieParts.join('; '),
|
||||
'X-User-Agent': STALKER_MAG_USER_AGENT,
|
||||
SN: playlist.stalkerSerialNumber || '',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const origin = getStalkerPortalOrigin(playlist);
|
||||
if (origin) {
|
||||
headers['Origin'] = origin;
|
||||
headers['Referer'] = origin;
|
||||
}
|
||||
|
||||
return Object.entries(headers).reduce<Record<string, string>>(
|
||||
(acc, [name, value]) => {
|
||||
if (value?.trim()) {
|
||||
acc[name] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
isVodSeriesItem,
|
||||
mapRegularSeriesEpisodes,
|
||||
mapRegularSeriesSeasons,
|
||||
mapVodSeriesEpisodes,
|
||||
mapVodSeriesSeasonsToVm,
|
||||
} from './stalker-series.adapters';
|
||||
import { StalkerSelectedVodItem } from './models';
|
||||
|
||||
type EpisodeWithMetadata = {
|
||||
custom_sid?: string;
|
||||
id?: string;
|
||||
originalId?: string;
|
||||
originalCmd?: string;
|
||||
};
|
||||
|
||||
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);
|
||||
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
|
||||
expect(firstEpisode.custom_sid).toBe('vod-series');
|
||||
expect(firstEpisode.originalId).toBe('api-shared-id');
|
||||
expect(firstEpisode.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);
|
||||
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
|
||||
expect(firstEpisode.custom_sid).toBe('regular-series');
|
||||
expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { XtreamSerieEpisode } from 'shared-interfaces';
|
||||
import {
|
||||
StalkerSeason,
|
||||
StalkerVodSeriesEpisode,
|
||||
StalkerVodSeriesSeason,
|
||||
StalkerVodSource,
|
||||
} from './models';
|
||||
import { isStalkerSeriesFlag } from './stalker-vod.utils';
|
||||
|
||||
export interface VodSeriesSeasonVm {
|
||||
id: string;
|
||||
video_id: string;
|
||||
name: string;
|
||||
season_number: string;
|
||||
episodes: StalkerVodSeriesEpisode[];
|
||||
isLoading: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerSeriesSeasonVm {
|
||||
id: string;
|
||||
name: string;
|
||||
cmd?: string;
|
||||
series: number[];
|
||||
}
|
||||
|
||||
export interface StalkerMappedEpisode extends XtreamSerieEpisode {
|
||||
originalId?: string;
|
||||
originalCmd?: string;
|
||||
}
|
||||
|
||||
function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function generateEpisodeId(
|
||||
seed: string,
|
||||
episodeNum: number,
|
||||
seasonKey: string,
|
||||
isVodSeries: boolean
|
||||
): number {
|
||||
if (isVodSeries) {
|
||||
return hashString(`vod_${seasonKey}_${episodeNum}`);
|
||||
}
|
||||
return hashString(`${seed}_ep_${episodeNum}`);
|
||||
}
|
||||
|
||||
function toEpisodeNumber(value: unknown): number {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown, fallback: string): string {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toSeriesNumbers(values: unknown[] | undefined): number[] {
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
}
|
||||
return values
|
||||
.map((value) => toEpisodeNumber(value))
|
||||
.filter((value) => value > 0);
|
||||
}
|
||||
|
||||
export function isVodSeriesItem(
|
||||
item: Pick<StalkerVodSource, 'is_series'> | null | undefined
|
||||
): boolean {
|
||||
return isStalkerSeriesFlag(item?.is_series);
|
||||
}
|
||||
|
||||
export function mapVodSeriesSeasonsToVm(
|
||||
seasons: StalkerVodSeriesSeason[] | undefined
|
||||
): VodSeriesSeasonVm[] {
|
||||
return (seasons ?? []).map((season) => ({
|
||||
id: String(season.id),
|
||||
video_id: String(season.video_id),
|
||||
name: toNonEmptyString(
|
||||
season.name,
|
||||
`Season ${toNonEmptyString(season.season_number, '0')}`
|
||||
),
|
||||
season_number: toNonEmptyString(season.season_number, ''),
|
||||
episodes: [],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapRegularSeriesSeasons(
|
||||
vodWithSeries: StalkerVodSource | null,
|
||||
serialSeasons: StalkerSeason[] | undefined
|
||||
): StalkerSeriesSeasonVm[] {
|
||||
if (vodWithSeries?.series && vodWithSeries.series.length > 0) {
|
||||
return [
|
||||
{
|
||||
id: String(vodWithSeries.id),
|
||||
name: vodWithSeries.info?.name || 'Episodes',
|
||||
cmd: vodWithSeries.cmd,
|
||||
series: toSeriesNumbers(vodWithSeries.series),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return (serialSeasons ?? []).map((season) => ({
|
||||
id: String(season.id),
|
||||
name: season.name,
|
||||
cmd: season.cmd,
|
||||
series: toSeriesNumbers(season.series),
|
||||
}));
|
||||
}
|
||||
|
||||
function createBaseEpisode(
|
||||
id: number,
|
||||
episodeNum: number,
|
||||
title: string,
|
||||
containerExtension: string,
|
||||
customSid: string,
|
||||
season: number,
|
||||
info: XtreamSerieEpisode['info']
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: String(id),
|
||||
episode_num: episodeNum,
|
||||
title,
|
||||
container_extension: containerExtension,
|
||||
info,
|
||||
custom_sid: customSid,
|
||||
added: '',
|
||||
season,
|
||||
direct_source: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVodSeriesEpisodes(
|
||||
seasons: ReadonlyArray<VodSeriesSeasonVm>,
|
||||
fallbackPoster?: string
|
||||
): Record<string, XtreamSerieEpisode[]> {
|
||||
const mapped: Record<string, XtreamSerieEpisode[]> = {};
|
||||
|
||||
seasons.forEach((season) => {
|
||||
const seasonKey = season.season_number || season.name || season.id;
|
||||
const seasonNum = toEpisodeNumber(seasonKey) || 1;
|
||||
|
||||
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
|
||||
const episodeNum =
|
||||
toEpisodeNumber(episode.series_number) ||
|
||||
toEpisodeNumber(episode.episode_num);
|
||||
const trackingId = generateEpisodeId(
|
||||
String(episode.id ?? ''),
|
||||
episodeNum,
|
||||
seasonKey,
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
...createBaseEpisode(
|
||||
trackingId,
|
||||
episodeNum,
|
||||
episode.name || `Episode ${episodeNum}`,
|
||||
'mpg',
|
||||
'vod-series',
|
||||
seasonNum,
|
||||
{
|
||||
movie_image: episode.cover || fallbackPoster,
|
||||
plot: episode.description || '',
|
||||
duration: episode.duration
|
||||
? `${episode.duration} min`
|
||||
: '',
|
||||
}
|
||||
),
|
||||
originalId: String(episode.id ?? ''),
|
||||
} as StalkerMappedEpisode;
|
||||
});
|
||||
});
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function mapRegularSeriesEpisodes(
|
||||
seasons: ReadonlyArray<StalkerSeriesSeasonVm>,
|
||||
fallbackPoster?: string
|
||||
): Record<string, XtreamSerieEpisode[]> {
|
||||
const mapped: Record<string, XtreamSerieEpisode[]> = {};
|
||||
|
||||
seasons.forEach((season, index) => {
|
||||
const seasonKey = String(index + 1);
|
||||
mapped[seasonKey] = (season.series ?? []).map((episodeNum) => {
|
||||
const trackingId = generateEpisodeId(
|
||||
String(season.cmd ?? ''),
|
||||
episodeNum,
|
||||
seasonKey,
|
||||
false
|
||||
);
|
||||
|
||||
return {
|
||||
...createBaseEpisode(
|
||||
trackingId,
|
||||
episodeNum,
|
||||
`Episode ${episodeNum}`,
|
||||
'',
|
||||
'regular-series',
|
||||
Number(seasonKey),
|
||||
{
|
||||
movie_image: fallbackPoster,
|
||||
}
|
||||
),
|
||||
originalCmd: season.cmd,
|
||||
} as StalkerMappedEpisode;
|
||||
});
|
||||
});
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string {
|
||||
return season.season_number || season.name || season.id;
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Playlist, STALKER_REQUEST } from 'shared-interfaces';
|
||||
import { DataService } from 'services';
|
||||
|
||||
/**
|
||||
* SHA1 hash using native Web Crypto API
|
||||
* Produces correct 40-character hex hash matching real Stalker clients
|
||||
*/
|
||||
async function sha1(str: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates SHA1 prehash from MAC address
|
||||
* This must match what real Stalker clients send
|
||||
*/
|
||||
async function generatePrehash(macAddress: string): Promise<string> {
|
||||
// Use MAC address with colons, uppercase - this is what most clients use
|
||||
const str = macAddress.toUpperCase();
|
||||
return (await sha1(str)).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random string for metrics
|
||||
*/
|
||||
function generateRandom(): string {
|
||||
const chars = 'abcdef0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 40; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic serial number - 13 hex characters
|
||||
* Constant value for consistency across all sessions
|
||||
*/
|
||||
export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF';
|
||||
const STALKER_WATCHDOG_INTERVAL_MS = 25_000;
|
||||
|
||||
export interface StalkerHandshakeResponse {
|
||||
js: {
|
||||
token: string;
|
||||
not_valid?: number;
|
||||
random?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StalkerProfileResponse {
|
||||
js: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
mac?: string;
|
||||
status?: number;
|
||||
msg?: string;
|
||||
block_msg?: string;
|
||||
account_info?: {
|
||||
login?: string;
|
||||
expire_date?: number;
|
||||
tariff_plan_name?: string;
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface StalkerAuthConfirmationResponse {
|
||||
js?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to manage Stalker portal session tokens.
|
||||
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
|
||||
* Persists tokens during session and handles re-authentication on auth failures.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StalkerSessionService {
|
||||
private dataService = inject(DataService);
|
||||
|
||||
// In-memory token cache for current session (keyed by playlist ID)
|
||||
private tokenCache = new Map<string, string>();
|
||||
|
||||
// Pending authentication promises to prevent race conditions
|
||||
// When multiple requests need a token simultaneously, they all wait for the same auth
|
||||
private pendingAuth = new Map<
|
||||
string,
|
||||
Promise<{ token: string; serialNumber?: string }>
|
||||
>();
|
||||
private watchdogIntervals = new Map<string, ReturnType<typeof setInterval>>();
|
||||
private watchdogPlaylists = new Map<string, Playlist>();
|
||||
private watchdogInFlight = new Set<string>();
|
||||
private activeWatchdogPlaylistId: string | null = null;
|
||||
|
||||
/**
|
||||
* Generates a consistent 64-character device ID from MAC address
|
||||
*/
|
||||
private generateDeviceId(macAddress: string): string {
|
||||
// Generate consistent device ID from MAC
|
||||
const str = macAddress.toUpperCase().replace(/:/g, '');
|
||||
const chars = '0123456789ABCDEF';
|
||||
let result = '';
|
||||
let seed = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
seed = (seed * 31 + str.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
for (let i = 0; i < 64; i++) {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
result += chars[seed % 16];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a full stalker portal URL (requires handshake)
|
||||
* Full stalker portal URLs contain /stalker_portal/ in the path
|
||||
*/
|
||||
isFullStalkerPortal(url: string): boolean {
|
||||
return (
|
||||
url.includes('/stalker_portal/') || url.includes('/server/load.php')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cached token for a playlist, or null if not cached
|
||||
*/
|
||||
getCachedToken(playlistId: string): string | null {
|
||||
return this.tokenCache.get(playlistId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a token in the cache
|
||||
*/
|
||||
setCachedToken(playlistId: string, token: string): void {
|
||||
this.tokenCache.set(playlistId, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached token for a playlist (e.g., on auth failure)
|
||||
*/
|
||||
clearCachedToken(playlistId: string): void {
|
||||
this.tokenCache.delete(playlistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets which playlist should receive periodic watchdog pings.
|
||||
* This keeps some Ministra/Stalker sessions alive for live playback.
|
||||
*/
|
||||
setActiveWatchdogPlaylist(playlist?: Playlist | null): void {
|
||||
const nextPlaylistId = playlist?._id ?? null;
|
||||
|
||||
if (
|
||||
this.activeWatchdogPlaylistId &&
|
||||
this.activeWatchdogPlaylistId !== nextPlaylistId
|
||||
) {
|
||||
this.stopWatchdog(this.activeWatchdogPlaylistId);
|
||||
}
|
||||
|
||||
this.activeWatchdogPlaylistId = nextPlaylistId;
|
||||
|
||||
if (
|
||||
!playlist ||
|
||||
!playlist.isFullStalkerPortal ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress
|
||||
) {
|
||||
if (nextPlaylistId) {
|
||||
this.stopWatchdog(nextPlaylistId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.startWatchdog(playlist);
|
||||
}
|
||||
|
||||
private startWatchdog(playlist: Playlist): void {
|
||||
const playlistId = playlist._id;
|
||||
this.watchdogPlaylists.set(playlistId, playlist);
|
||||
|
||||
if (this.watchdogIntervals.has(playlistId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.sendWatchdogPing(playlistId, '1');
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void this.sendWatchdogPing(playlistId, '0');
|
||||
}, STALKER_WATCHDOG_INTERVAL_MS);
|
||||
|
||||
this.watchdogIntervals.set(playlistId, intervalId);
|
||||
}
|
||||
|
||||
private stopWatchdog(playlistId: string): void {
|
||||
const intervalId = this.watchdogIntervals.get(playlistId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
this.watchdogIntervals.delete(playlistId);
|
||||
}
|
||||
this.watchdogPlaylists.delete(playlistId);
|
||||
this.watchdogInFlight.delete(playlistId);
|
||||
}
|
||||
|
||||
private async sendWatchdogPing(
|
||||
playlistId: string,
|
||||
init: '0' | '1'
|
||||
): Promise<void> {
|
||||
if (this.watchdogInFlight.has(playlistId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = this.watchdogPlaylists.get(playlistId);
|
||||
if (
|
||||
!playlist ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress ||
|
||||
!playlist.isFullStalkerPortal
|
||||
) {
|
||||
this.stopWatchdog(playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.watchdogInFlight.add(playlistId);
|
||||
try {
|
||||
await this.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
{
|
||||
type: 'watchdog',
|
||||
action: 'get_events',
|
||||
event_active_id: '0',
|
||||
cur_play_type: '0',
|
||||
init,
|
||||
JsHttpRequest: '1-xml',
|
||||
},
|
||||
false
|
||||
);
|
||||
} catch (error) {
|
||||
// Keep failures non-fatal; next interval can recover after token refresh.
|
||||
console.warn('[StalkerSession] Watchdog ping failed:', error);
|
||||
} finally {
|
||||
this.watchdogInFlight.delete(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs handshake to get a session token for a full stalker portal
|
||||
* Returns both the token and the random value for use in subsequent requests
|
||||
*/
|
||||
async performHandshake(
|
||||
portalUrl: string,
|
||||
macAddress: string
|
||||
): Promise<{ token: string; random: string }> {
|
||||
const prehash = await generatePrehash(macAddress);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'handshake',
|
||||
token: '',
|
||||
prehash,
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
const response: StalkerHandshakeResponse =
|
||||
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
if (response?.js?.token) {
|
||||
return {
|
||||
token: response.js.token,
|
||||
random: response.js.random || generateRandom(),
|
||||
};
|
||||
}
|
||||
|
||||
console.error('[StalkerSession] No token in response');
|
||||
throw new Error('Handshake failed: No token received');
|
||||
} catch (error) {
|
||||
console.error('[StalkerSession] Handshake error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets account profile information to validate the portal and check subscription
|
||||
* Based on working implementation from stalker-to-m3u repo
|
||||
*/
|
||||
async getProfile(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
token: string,
|
||||
serialNumber: string,
|
||||
handshakeRandom: string,
|
||||
providedDeviceId1?: string,
|
||||
providedDeviceId2?: string,
|
||||
providedSignature1?: string,
|
||||
providedSignature2?: string
|
||||
): Promise<StalkerProfileResponse> {
|
||||
// Use provided device IDs or generate from MAC (64 hex chars each)
|
||||
const deviceId1 =
|
||||
providedDeviceId1?.trim() || this.generateDeviceId(macAddress);
|
||||
const deviceId2 = providedDeviceId2?.trim() || deviceId1;
|
||||
|
||||
// Use provided signatures or empty string (some portals don't require them)
|
||||
const signature1 = providedSignature1?.trim() || '';
|
||||
const signature2 = providedSignature2?.trim() || '';
|
||||
|
||||
// Build metrics JSON matching working app
|
||||
const metrics = {
|
||||
mac: macAddress,
|
||||
model: 'MAG250',
|
||||
type: 'STB',
|
||||
random: handshakeRandom,
|
||||
sn: serialNumber,
|
||||
};
|
||||
|
||||
// Generate prehash for get_profile (same as handshake)
|
||||
const prehash = await generatePrehash(macAddress);
|
||||
|
||||
// Profile request matching working StalkerTV app
|
||||
// auth_second_step=1, includes metrics, prehash, and device_id params
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
hd: '1',
|
||||
not_valid_token: '0',
|
||||
video_out: 'hdmi',
|
||||
auth_second_step: '1',
|
||||
num_banks: '2',
|
||||
metrics: JSON.stringify(metrics),
|
||||
sn: serialNumber,
|
||||
device_id: deviceId1,
|
||||
device_id2: deviceId2,
|
||||
signature: signature1,
|
||||
...(signature2 ? { signature2: signature2 } : {}),
|
||||
prehash: prehash,
|
||||
stb_type: '',
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
const response: StalkerProfileResponse =
|
||||
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[StalkerSession] Get profile error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs do_auth to authenticate the session after handshake
|
||||
*/
|
||||
async doAuth(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
token: string
|
||||
): Promise<boolean> {
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'do_auth',
|
||||
login: '',
|
||||
password: '',
|
||||
device_id: '',
|
||||
device_id2: '',
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
}
|
||||
);
|
||||
|
||||
// do_auth returns { js: true } on success
|
||||
if (response?.js === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[StalkerSession] do_auth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces)
|
||||
* Returns the token and account info if successful
|
||||
*/
|
||||
async authenticate(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
serialNumber: string,
|
||||
deviceId1?: string,
|
||||
deviceId2?: string,
|
||||
signature1?: string,
|
||||
signature2?: string
|
||||
): Promise<{
|
||||
token: string;
|
||||
accountInfo?: StalkerProfileResponse['js']['account_info'];
|
||||
}> {
|
||||
// Step 1: Handshake to get token and random
|
||||
const { token, random } = await this.performHandshake(
|
||||
portalUrl,
|
||||
macAddress
|
||||
);
|
||||
|
||||
// Step 2: Get profile to activate token and get account info
|
||||
// The random from handshake must be used in auth_second_step
|
||||
try {
|
||||
const profileResponse = await this.getProfile(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
token,
|
||||
serialNumber,
|
||||
random,
|
||||
deviceId1,
|
||||
deviceId2,
|
||||
signature1,
|
||||
signature2
|
||||
);
|
||||
|
||||
// Check for profile-level errors
|
||||
if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) {
|
||||
const errorMsg =
|
||||
profileResponse.js.msg ||
|
||||
profileResponse.js.block_msg ||
|
||||
'Unknown profile error';
|
||||
console.error('[StalkerSession] Profile error:', errorMsg);
|
||||
throw new Error(`Profile error: ${errorMsg}`);
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
accountInfo: profileResponse?.js?.account_info,
|
||||
};
|
||||
} catch (error) {
|
||||
// Profile fetch failed - this is a real error, propagate it
|
||||
console.error('[StalkerSession] Profile fetch failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a valid token exists for a playlist, performing full auth if needed
|
||||
* IMPORTANT: Based on working traces, each session needs handshake + get_profile
|
||||
* Returns the token to use for requests, and the serial number to store
|
||||
*/
|
||||
async ensureToken(
|
||||
playlist: Playlist
|
||||
): Promise<{ token: string | null; serialNumber?: string }> {
|
||||
// If not a full stalker portal, no token needed
|
||||
if (!playlist.isFullStalkerPortal) {
|
||||
return { token: null };
|
||||
}
|
||||
|
||||
// Check in-memory cache first (valid for current session only)
|
||||
const cachedToken = this.getCachedToken(playlist._id);
|
||||
if (cachedToken) {
|
||||
return { token: cachedToken };
|
||||
}
|
||||
|
||||
// Check if there's already a pending authentication for this playlist
|
||||
// This prevents race conditions when multiple resources request a token simultaneously
|
||||
const pendingPromise = this.pendingAuth.get(playlist._id);
|
||||
if (pendingPromise) {
|
||||
console.log(
|
||||
'[StalkerSession] Waiting for pending authentication...'
|
||||
);
|
||||
return pendingPromise;
|
||||
}
|
||||
|
||||
// No cached token - need to do full authentication (handshake + get_profile)
|
||||
// Don't trust stored tokens as they may be from a different session
|
||||
if (!playlist.portalUrl || !playlist.macAddress) {
|
||||
console.error('[StalkerSession] Missing portal URL or MAC address');
|
||||
throw new Error('Portal URL and MAC address are required');
|
||||
}
|
||||
|
||||
// Get or generate serial number - must be consistent for the MAC
|
||||
let serialNumber = playlist.stalkerSerialNumber;
|
||||
if (!serialNumber) {
|
||||
serialNumber = STALKER_SERIAL_NUMBER;
|
||||
}
|
||||
|
||||
// Create the authentication promise and store it to prevent concurrent auth attempts
|
||||
// Use async/await wrapper to properly clean up on both success and failure
|
||||
const authPromise = (async () => {
|
||||
try {
|
||||
const { token } = await this.authenticate(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
serialNumber
|
||||
);
|
||||
this.setCachedToken(playlist._id, token);
|
||||
return { token, serialNumber };
|
||||
} finally {
|
||||
// Clean up pending promise regardless of success/failure
|
||||
this.pendingAuth.delete(playlist._id);
|
||||
}
|
||||
})();
|
||||
|
||||
// Store the pending promise so other concurrent requests can wait on it
|
||||
this.pendingAuth.set(playlist._id, authPromise);
|
||||
|
||||
return authPromise;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a response or error indicates an authorization failure
|
||||
*/
|
||||
private isAuthorizationError(responseOrError: unknown): boolean {
|
||||
if (!responseOrError) return false;
|
||||
|
||||
const response = responseOrError as Record<string, unknown>;
|
||||
|
||||
// Convert response to string for pattern matching
|
||||
const responseStr = JSON.stringify(responseOrError).toLowerCase();
|
||||
|
||||
// Check for "Authorization failed. XX" pattern (like "Authorization failed. 75")
|
||||
if (/authorization\s*failed\.?\s*\d*/i.test(responseStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for common auth failure indicators
|
||||
const jsData = response?.['js'] as Record<string, unknown>;
|
||||
const errorMessage =
|
||||
(response?.['message'] as string)?.toLowerCase?.() ||
|
||||
jsData?.['error']?.toString?.().toLowerCase?.() ||
|
||||
jsData?.['msg']?.toString?.().toLowerCase?.() ||
|
||||
'';
|
||||
|
||||
return (
|
||||
errorMessage.includes('authorization') ||
|
||||
errorMessage.includes('unauthorized') ||
|
||||
errorMessage.includes('auth failed') ||
|
||||
errorMessage.includes('invalid token') ||
|
||||
response?.['status'] === 401 ||
|
||||
jsData?.['error'] === 'Authorization failed'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for making stalker requests with automatic token handling and retry on auth failure
|
||||
* This should be used by all stalker API calls to ensure proper auth handling
|
||||
*/
|
||||
async makeAuthenticatedRequest<T>(
|
||||
playlist: Playlist,
|
||||
params: Record<string, string | number>,
|
||||
retryOnAuthFailure = true
|
||||
): Promise<T> {
|
||||
// Get token (will wait if auth is in progress)
|
||||
const { token } = await this.ensureToken(playlist);
|
||||
const serialNumber = playlist.stalkerSerialNumber;
|
||||
|
||||
try {
|
||||
const response = await this.dataService.sendIpcEvent<T>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
}
|
||||
);
|
||||
|
||||
// Check for authorization failure in response
|
||||
if (this.isAuthorizationError(response)) {
|
||||
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
|
||||
// Clear cached token to force re-authentication
|
||||
this.clearCachedToken(playlist._id);
|
||||
// Retry once with fresh authentication
|
||||
return this.makeAuthenticatedRequest<T>(
|
||||
playlist,
|
||||
params,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Authorization failed after retry');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Check if error indicates auth failure
|
||||
if (
|
||||
this.isAuthorizationError(error) &&
|
||||
retryOnAuthFailure &&
|
||||
playlist.isFullStalkerPortal
|
||||
) {
|
||||
// Clear cached token and retry with new handshake
|
||||
this.clearCachedToken(playlist._id);
|
||||
return this.makeAuthenticatedRequest<T>(
|
||||
playlist,
|
||||
params,
|
||||
false
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { VodDetailsItem } from 'shared-interfaces';
|
||||
import { StalkerFavoriteItem } from './models';
|
||||
import {
|
||||
buildStalkerFavoritePayload,
|
||||
createStalkerInlineDetailState,
|
||||
createStalkerDetailViewState,
|
||||
normalizeStalkerFavoriteItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from './stalker-vod.utils';
|
||||
|
||||
describe('stalker-vod.utils regressions', () => {
|
||||
it('routes embedded series[] items to series view state', () => {
|
||||
const state = createStalkerDetailViewState(
|
||||
{
|
||||
id: '101',
|
||||
cmd: '/media/file_101.mpg',
|
||||
series: [1, 2, 3],
|
||||
info: {
|
||||
name: 'Series in VOD',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
'playlist-1'
|
||||
);
|
||||
|
||||
expect(state.itemDetails?.series?.length).toBe(3);
|
||||
expect(state.vodDetailsItem).toBeNull();
|
||||
});
|
||||
|
||||
it('routes is_series=1 items to series view state (lazy season flow)', () => {
|
||||
const state = createStalkerDetailViewState(
|
||||
{
|
||||
id: '1507',
|
||||
cmd: '/media/file_1507.mpg',
|
||||
is_series: true,
|
||||
info: {
|
||||
name: 'Flagged Series',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
'playlist-1'
|
||||
);
|
||||
|
||||
expect(state.itemDetails?.is_series).toBe(true);
|
||||
expect(state.vodDetailsItem).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a playable VOD details item for regular movies', () => {
|
||||
const state = createStalkerDetailViewState(
|
||||
{
|
||||
id: '42',
|
||||
cmd: '/media/file_42.mpg',
|
||||
info: {
|
||||
name: 'Regular Movie',
|
||||
movie_image: 'poster.jpg',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
'playlist-1'
|
||||
);
|
||||
|
||||
expect(state.itemDetails?.id).toBe('42');
|
||||
expect(state.vodDetailsItem).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'stalker',
|
||||
playlistId: 'playlist-1',
|
||||
cmd: '/media/file_42.mpg',
|
||||
})
|
||||
);
|
||||
expect(state.vodDetailsItem?.data.id).toBe('42');
|
||||
});
|
||||
|
||||
it('favorite toggle uses completion callback path without delayed state update', () => {
|
||||
const addToFavorites = jest.fn(
|
||||
(_item: Record<string, unknown>, onDone?: () => void) => {
|
||||
onDone?.();
|
||||
}
|
||||
);
|
||||
const removeFromFavorites = jest.fn(
|
||||
(_id: string, onDone?: () => void) => {
|
||||
onDone?.();
|
||||
}
|
||||
);
|
||||
const onComplete = jest.fn();
|
||||
|
||||
const item = {
|
||||
type: 'stalker',
|
||||
data: {
|
||||
id: '42',
|
||||
cmd: '/media/file_42.mpg',
|
||||
info: {
|
||||
name: 'Movie',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
playlistId: 'playlist-1',
|
||||
vodId: 42,
|
||||
cmd: '/media/file_42.mpg',
|
||||
} as unknown as VodDetailsItem;
|
||||
|
||||
toggleStalkerVodFavorite(
|
||||
{ item, isFavorite: true },
|
||||
{ addToFavorites, removeFromFavorites, onComplete }
|
||||
);
|
||||
expect(addToFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(removeFromFavorites).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
|
||||
onComplete.mockClear();
|
||||
toggleStalkerVodFavorite(
|
||||
{ item, isFavorite: false },
|
||||
{ addToFavorites, removeFromFavorites, onComplete }
|
||||
);
|
||||
expect(removeFromFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves the concrete category id in favorite payloads', () => {
|
||||
expect(
|
||||
buildStalkerFavoritePayload({
|
||||
id: '42',
|
||||
cmd: '/media/file_42.mpg',
|
||||
info: {
|
||||
name: 'Movie',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
category_id: '17',
|
||||
} as unknown as Parameters<typeof buildStalkerFavoritePayload>[0])
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: '42',
|
||||
category_id: '17',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes nested favorite details for the shared inline detail shell', () => {
|
||||
const detailState = createStalkerInlineDetailState(
|
||||
normalizeStalkerFavoriteItem({
|
||||
id: '7',
|
||||
category_id: 'vod',
|
||||
name: 'Nested Movie',
|
||||
details: {
|
||||
id: '7',
|
||||
cmd: '/media/file_7.mpg',
|
||||
series: [11, 12],
|
||||
info: {
|
||||
name: 'Nested Movie',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
} as StalkerFavoriteItem),
|
||||
null
|
||||
);
|
||||
|
||||
expect(detailState.categoryId).toBe('vod');
|
||||
expect(detailState.seriesItem?.series).toEqual([11, 12]);
|
||||
expect(detailState.isSeries).toBe(false);
|
||||
expect(detailState.vodDetailsItem).toBeNull();
|
||||
});
|
||||
|
||||
it('allows search to force series mode when results omit category metadata', () => {
|
||||
const detailState = createStalkerInlineDetailState(
|
||||
{
|
||||
id: '9',
|
||||
cmd: '/media/file_9.mpg',
|
||||
info: {
|
||||
name: 'Series Result',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
null,
|
||||
'series'
|
||||
);
|
||||
|
||||
expect(detailState.categoryId).toBe('series');
|
||||
expect(detailState.seriesItem?.id).toBe('9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
signal,
|
||||
WritableSignal,
|
||||
} from '@angular/core';
|
||||
import { rxResource } from '@angular/core/rxjs-interop';
|
||||
import { Observable } from 'rxjs';
|
||||
import { PlaylistsService } from 'services';
|
||||
import {
|
||||
createStalkerVodItem,
|
||||
StalkerVodDetails,
|
||||
StalkerVodInfo,
|
||||
VodDetailsItem,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
StalkerFavoriteItem,
|
||||
StalkerSelectedVodItem,
|
||||
StalkerSeriesFlag,
|
||||
StalkerVodSource,
|
||||
} from './models/stalker-favorite-item.interface';
|
||||
|
||||
export interface StalkerDetailViewState {
|
||||
itemDetails: StalkerSelectedVodItem | null;
|
||||
vodDetailsItem: VodDetailsItem | null;
|
||||
}
|
||||
|
||||
export interface StalkerInlineDetailState {
|
||||
categoryId: 'vod' | 'series' | null;
|
||||
seriesItem: StalkerSelectedVodItem | null;
|
||||
isSeries: boolean;
|
||||
vodDetailsItem: VodDetailsItem | null;
|
||||
}
|
||||
|
||||
export interface StalkerFavoriteToggleEvent {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerFavoriteToggleOptions {
|
||||
addToFavorites: (
|
||||
item: Record<string, unknown>,
|
||||
onComplete?: () => void
|
||||
) => void;
|
||||
removeFromFavorites: (
|
||||
favoriteId: string,
|
||||
onComplete?: () => void
|
||||
) => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function isStalkerSeriesFlag(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
export function normalizeStalkerSeriesFlag(
|
||||
value: unknown
|
||||
): StalkerSeriesFlag | undefined {
|
||||
if (value === true || value === 1 || value === '1') {
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isStalkerSeriesItem(item: {
|
||||
is_series?: unknown;
|
||||
series?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
isStalkerSeriesFlag(item?.is_series) ||
|
||||
(Array.isArray(item?.series) && item.series.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStalkerFavoritePayload(
|
||||
data: StalkerVodDetails
|
||||
): Record<string, unknown> {
|
||||
const categoryId = String(
|
||||
(data as StalkerVodDetails & { category_id?: string | number })
|
||||
.category_id ?? 'vod'
|
||||
).trim();
|
||||
|
||||
return {
|
||||
...data,
|
||||
category_id: categoryId || 'vod',
|
||||
title: data.info?.name,
|
||||
cover: data.info?.movie_image,
|
||||
added_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesFavoriteById(
|
||||
favorite: Partial<StalkerFavoriteItem>,
|
||||
itemId: string | number
|
||||
): boolean {
|
||||
const expectedId = normalizeStalkerEntityId(itemId);
|
||||
return (
|
||||
normalizeStalkerEntityId(favorite?.id) === expectedId ||
|
||||
normalizeStalkerEntityId(favorite?.movie_id) === expectedId ||
|
||||
normalizeStalkerEntityId(favorite?.stream_id) === expectedId ||
|
||||
normalizeStalkerEntityId(favorite?.series_id) === expectedId
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeStalkerEntityId(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function normalizeStalkerEntityIdAsNumber(
|
||||
value: unknown
|
||||
): number | null {
|
||||
const normalized = normalizeStalkerEntityId(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toStringOrFallback(value: unknown, fallback = ''): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toOptionalString(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
|
||||
const info = item.info ?? {};
|
||||
|
||||
return {
|
||||
name: toStringOrFallback(
|
||||
info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title,
|
||||
'Unknown'
|
||||
),
|
||||
o_name: toOptionalString(info.o_name ?? item.o_name),
|
||||
movie_image: toStringOrFallback(
|
||||
info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo
|
||||
),
|
||||
description: toStringOrFallback(info.description ?? item.description),
|
||||
actors: toStringOrFallback(info.actors ?? item.actors),
|
||||
director: toStringOrFallback(info.director ?? item.director),
|
||||
releasedate: toStringOrFallback(
|
||||
info.releasedate ?? item.releasedate ?? item.year
|
||||
),
|
||||
genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str),
|
||||
rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb),
|
||||
rating_kinopoisk: toStringOrFallback(
|
||||
info.rating_kinopoisk ?? item.rating_kinopoisk
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStalkerSelectedVodItem(
|
||||
item: StalkerVodSource,
|
||||
forceSeries = false
|
||||
): StalkerSelectedVodItem {
|
||||
return {
|
||||
id: toStringOrFallback(item.id ?? item.stream_id),
|
||||
cmd: toStringOrFallback(item.cmd),
|
||||
series: item.series,
|
||||
has_files: item.has_files,
|
||||
is_series:
|
||||
forceSeries || isStalkerSeriesFlag(item?.is_series)
|
||||
? true
|
||||
: undefined,
|
||||
video_id: item.video_id,
|
||||
category_id:
|
||||
typeof item.category_id === 'string'
|
||||
? item.category_id
|
||||
: undefined,
|
||||
info: createStalkerInfo(item),
|
||||
};
|
||||
}
|
||||
|
||||
export interface NormalizedStalkerFavoriteItem extends StalkerFavoriteItem {
|
||||
details: StalkerSelectedVodItem;
|
||||
}
|
||||
|
||||
export function normalizeStalkerFavoriteItem(
|
||||
item: StalkerFavoriteItem
|
||||
): NormalizedStalkerFavoriteItem {
|
||||
if (item.details && item.details.info) {
|
||||
return item as NormalizedStalkerFavoriteItem;
|
||||
}
|
||||
|
||||
const source = (item.details || item) as StalkerVodSource;
|
||||
const info = createStalkerInfo({
|
||||
...item,
|
||||
...source,
|
||||
});
|
||||
const normalizedIsSeries = normalizeStalkerSeriesFlag(
|
||||
(item as StalkerVodSource).is_series ?? source.is_series
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
details: {
|
||||
...source,
|
||||
info,
|
||||
cmd: toStringOrFallback(
|
||||
(item as StalkerVodSource).cmd ?? source.cmd
|
||||
),
|
||||
id: toStringOrFallback(item.stream_id ?? item.id ?? source.id),
|
||||
series: (item as StalkerVodSource).series ?? source.series,
|
||||
is_series: normalizedIsSeries ? true : undefined,
|
||||
video_id:
|
||||
(item as StalkerVodSource).video_id ?? source.video_id,
|
||||
category_id:
|
||||
typeof (item as StalkerVodSource).category_id === 'string'
|
||||
? (item as StalkerVodSource).category_id
|
||||
: typeof source.category_id === 'string'
|
||||
? source.category_id
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStalkerVodDetailsItem(
|
||||
item: StalkerVodSource
|
||||
): StalkerSelectedVodItem {
|
||||
return {
|
||||
...buildStalkerSelectedVodItem(item),
|
||||
info: createStalkerInfo(item),
|
||||
is_series: normalizeStalkerSeriesFlag(item?.is_series)
|
||||
? true
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStalkerInlineDetailState(
|
||||
itemDetails: StalkerSelectedVodItem | NormalizedStalkerFavoriteItem | null,
|
||||
vodDetailsItem: VodDetailsItem | null,
|
||||
categoryOverride?: 'vod' | 'series' | null
|
||||
): StalkerInlineDetailState {
|
||||
if (!itemDetails) {
|
||||
return {
|
||||
categoryId: null,
|
||||
seriesItem: null,
|
||||
isSeries: false,
|
||||
vodDetailsItem: null,
|
||||
};
|
||||
}
|
||||
|
||||
const seriesItem =
|
||||
'details' in itemDetails ? itemDetails.details : itemDetails;
|
||||
const rawCategoryId =
|
||||
categoryOverride ??
|
||||
(typeof itemDetails.category_id === 'string'
|
||||
? itemDetails.category_id
|
||||
: typeof seriesItem.category_id === 'string'
|
||||
? seriesItem.category_id
|
||||
: undefined);
|
||||
const categoryId =
|
||||
rawCategoryId === 'vod' || rawCategoryId === 'movie'
|
||||
? 'vod'
|
||||
: rawCategoryId === 'series'
|
||||
? 'series'
|
||||
: null;
|
||||
|
||||
return {
|
||||
categoryId,
|
||||
seriesItem,
|
||||
isSeries: isStalkerSeriesFlag(seriesItem?.is_series),
|
||||
vodDetailsItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStalkerDetailViewState(
|
||||
normalizedItem: StalkerSelectedVodItem,
|
||||
playlistId: string
|
||||
): StalkerDetailViewState {
|
||||
if (isStalkerSeriesItem(normalizedItem)) {
|
||||
return {
|
||||
itemDetails: normalizedItem,
|
||||
vodDetailsItem: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
itemDetails: normalizedItem,
|
||||
vodDetailsItem: createStalkerVodItem(
|
||||
normalizedItem as StalkerVodDetails,
|
||||
playlistId
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearStalkerDetailViewState(): StalkerDetailViewState {
|
||||
return {
|
||||
itemDetails: null,
|
||||
vodDetailsItem: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRefreshTrigger(): {
|
||||
refreshVersion: WritableSignal<number>;
|
||||
refresh: () => void;
|
||||
} {
|
||||
const refreshVersion = signal<number>(Date.now());
|
||||
return {
|
||||
refreshVersion,
|
||||
refresh: () => refreshVersion.set(Date.now()),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPortalFavoritesResource(
|
||||
playlistService: PlaylistsService,
|
||||
getPortalId: () => string | undefined,
|
||||
getRefreshVersion: () => number
|
||||
) {
|
||||
return createPortalCollectionResource(
|
||||
playlistService,
|
||||
getPortalId,
|
||||
getRefreshVersion,
|
||||
(service, portalId) => service.getPortalFavorites(portalId)
|
||||
);
|
||||
}
|
||||
|
||||
export function createPortalCollectionResource<T>(
|
||||
playlistService: PlaylistsService,
|
||||
getPortalId: () => string | undefined,
|
||||
getRefreshVersion: () => number,
|
||||
streamFactory: (
|
||||
playlistService: PlaylistsService,
|
||||
portalId: string
|
||||
) => Observable<T[]>
|
||||
) {
|
||||
return rxResource({
|
||||
params: () => {
|
||||
const portalId = getPortalId();
|
||||
if (!portalId) return undefined;
|
||||
return {
|
||||
portalId,
|
||||
refreshVersion: getRefreshVersion(),
|
||||
};
|
||||
},
|
||||
stream: ({ params }) =>
|
||||
streamFactory(playlistService, params.portalId),
|
||||
});
|
||||
}
|
||||
|
||||
export function isSelectedStalkerVodFavorite(
|
||||
item: VodDetailsItem | null,
|
||||
favorites: ReadonlyArray<StalkerFavoriteItem> | undefined
|
||||
): boolean {
|
||||
if (!item || item.type !== 'stalker') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (favorites ?? []).some((favorite) =>
|
||||
matchesFavoriteById(favorite, item.data.id)
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleStalkerVodFavorite(
|
||||
event: StalkerFavoriteToggleEvent,
|
||||
options: StalkerFavoriteToggleOptions
|
||||
): void {
|
||||
if (event.item.type !== 'stalker') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.isFavorite) {
|
||||
options.addToFavorites(
|
||||
buildStalkerFavoritePayload(event.item.data),
|
||||
options.onComplete
|
||||
);
|
||||
} else {
|
||||
options.removeFromFavorites(event.item.data.id, options.onComplete);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
import { StalkerStore } from './stalker.store';
|
||||
|
||||
describe('StalkerStore API compatibility smoke', () => {
|
||||
let store: StalkerStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerStore,
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: {
|
||||
isElectron: false,
|
||||
sendIpcEvent: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
ensureToken: jest.fn(),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
openPlayer: jest.fn(),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
addPortalFavorite: jest.fn(() => of(undefined)),
|
||||
removeFromPortalFavorites: jest.fn(() => of(undefined)),
|
||||
addPortalRecentlyViewed: jest.fn(() => of(undefined)),
|
||||
removeFromPortalRecentlyViewed: jest.fn(() =>
|
||||
of(undefined)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
dispatch: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(StalkerStore);
|
||||
});
|
||||
|
||||
it('exposes compatibility state signals', () => {
|
||||
const expectedSignals = [
|
||||
'selectedContentType',
|
||||
'selectedCategoryId',
|
||||
'selectedVodId',
|
||||
'selectedSerialId',
|
||||
'selectedItvId',
|
||||
'limit',
|
||||
'page',
|
||||
'searchPhrase',
|
||||
'currentPlaylist',
|
||||
'totalCount',
|
||||
'selectedItem',
|
||||
'vodCategories',
|
||||
'seriesCategories',
|
||||
'itvCategories',
|
||||
'hasMoreChannels',
|
||||
'itvChannels',
|
||||
'vodSeriesSeasons',
|
||||
'vodSeriesEpisodes',
|
||||
'selectedVodSeriesSeasonId',
|
||||
];
|
||||
|
||||
for (const signalName of expectedSignals) {
|
||||
expect(typeof store[signalName]).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes compatibility computed selectors', () => {
|
||||
const expectedComputed = [
|
||||
'getTotalPages',
|
||||
'getPaginatedContent',
|
||||
'isPaginatedContentLoading',
|
||||
'isPaginatedContentFailed',
|
||||
'getSerialSeasonsResource',
|
||||
'isSerialSeasonsLoading',
|
||||
'getVodSeriesSeasonsResource',
|
||||
'isVodSeriesSeasonsLoading',
|
||||
'getCategoryResource',
|
||||
'isCategoryResourceLoading',
|
||||
'isCategoryResourceFailed',
|
||||
'getSelectedCategoryName',
|
||||
];
|
||||
|
||||
for (const computedName of expectedComputed) {
|
||||
expect(typeof store[computedName]).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes compatibility methods and internal resources', () => {
|
||||
const expectedMethods = [
|
||||
'setSelectedContentType',
|
||||
'setSelectedCategory',
|
||||
'setSelectedSerialId',
|
||||
'setSelectedVodId',
|
||||
'setSelectedItvId',
|
||||
'setLimit',
|
||||
'setPage',
|
||||
'setCurrentPlaylist',
|
||||
'setSelectedItem',
|
||||
'clearSelectedItem',
|
||||
'setCategories',
|
||||
'resetCategories',
|
||||
'setItvChannels',
|
||||
'setSearchPhrase',
|
||||
'fetchVodSeriesEpisodes',
|
||||
'getSelectedCategory',
|
||||
'fetchLinkToPlay',
|
||||
'getExpireDate',
|
||||
'addToFavorites',
|
||||
'removeFromFavorites',
|
||||
'fetchMovieFileId',
|
||||
'resolveVodPlayback',
|
||||
'createLinkToPlayVod',
|
||||
'addToRecentlyViewed',
|
||||
'removeFromRecentlyViewed',
|
||||
'fetchChannelEpg',
|
||||
'makeStalkerRequest',
|
||||
];
|
||||
|
||||
for (const methodName of expectedMethods) {
|
||||
expect(typeof store[methodName]).toBe('function');
|
||||
}
|
||||
|
||||
expect(store.getContentResource).toBeDefined();
|
||||
expect(typeof store.getContentResource.value).toBe('function');
|
||||
|
||||
expect(store.serialSeasonsResource).toBeDefined();
|
||||
expect(typeof store.serialSeasonsResource.value).toBe('function');
|
||||
|
||||
expect(store.vodSeriesSeasonsResource).toBeDefined();
|
||||
expect(typeof store.vodSeriesSeasonsResource.value).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { signalStore } from '@ngrx/signals';
|
||||
import {
|
||||
withStalkerContent,
|
||||
withStalkerEpg,
|
||||
withStalkerFavorites,
|
||||
withStalkerPlayer,
|
||||
withStalkerPortal,
|
||||
withStalkerRecent,
|
||||
withStalkerSelection,
|
||||
withStalkerSeries,
|
||||
} from './stores/features';
|
||||
|
||||
/**
|
||||
* StalkerStore facade composed from feature slices.
|
||||
* Public API compatibility is preserved while implementation is split by concern.
|
||||
*/
|
||||
export const StalkerStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withStalkerPortal(),
|
||||
withStalkerSelection(),
|
||||
withStalkerContent(),
|
||||
withStalkerSeries(),
|
||||
withStalkerPlayer(),
|
||||
withStalkerFavorites(),
|
||||
withStalkerRecent(),
|
||||
withStalkerEpg()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './with-stalker-content.feature';
|
||||
export * from './with-stalker-epg.feature';
|
||||
export * from './with-stalker-favorites.feature';
|
||||
export * from './with-stalker-player.feature';
|
||||
export * from './with-stalker-portal.feature';
|
||||
export * from './with-stalker-recent.feature';
|
||||
export * from './with-stalker-selection.feature';
|
||||
export * from './with-stalker-series.feature';
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DataService } from 'services';
|
||||
import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { withStalkerContent } from './with-stalker-content.feature';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const PLAYLIST = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Demo Stalker',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
importDate: '2026-04-14T00:00:00.000Z',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
const TestContentStore = signalStore(
|
||||
withState({
|
||||
currentPlaylist: undefined as PlaylistMeta | undefined,
|
||||
selectedContentType: 'vod' as 'vod' | 'series' | 'itv',
|
||||
selectedCategoryId: undefined as string | null | undefined,
|
||||
searchPhrase: '',
|
||||
page: 0,
|
||||
limit: 14,
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
},
|
||||
setSelectedContentType(type: 'vod' | 'series' | 'itv') {
|
||||
patchState(store, { selectedContentType: type });
|
||||
},
|
||||
setSelectedCategory(id: string | null | undefined) {
|
||||
patchState(store, { selectedCategoryId: id });
|
||||
},
|
||||
setPage(page: number) {
|
||||
patchState(store, { page });
|
||||
},
|
||||
})),
|
||||
withStalkerContent()
|
||||
);
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createContentResponse(title: string) {
|
||||
return {
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: title,
|
||||
name: title,
|
||||
category_id: '5',
|
||||
},
|
||||
],
|
||||
total_items: 28,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushResources(): Promise<void> {
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
attempts = 20
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await flushResources();
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for resource activity');
|
||||
}
|
||||
|
||||
describe('withStalkerContent failure states', () => {
|
||||
let store: InstanceType<typeof TestContentStore>;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestContentStore,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestContentStore);
|
||||
});
|
||||
|
||||
it('normalizes category failures into empty arrays and explicit error state', async () => {
|
||||
dataService.sendIpcEvent.mockRejectedValue(
|
||||
new Error('get_genres failed')
|
||||
);
|
||||
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isCategoryResourceLoading();
|
||||
|
||||
await waitForCondition(
|
||||
() => dataService.sendIpcEvent.mock.calls.length > 0
|
||||
);
|
||||
await flushResources();
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
url: PLAYLIST.portalUrl,
|
||||
macAddress: PLAYLIST.macAddress,
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.GetGenres,
|
||||
type: 'itv',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(store.getCategoryResource()).toEqual([]);
|
||||
expect(store.itvCategories()).toEqual([]);
|
||||
expect(store.isCategoryResourceFailed()).toBeInstanceOf(Error);
|
||||
expect((store.isCategoryResourceFailed() as Error).message).toBe(
|
||||
'get_genres failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the selected category from the normalized category collections', () => {
|
||||
store.setSelectedContentType('series');
|
||||
store.setCategories('series', [
|
||||
{
|
||||
category_id: '7',
|
||||
category_name: 'Drama',
|
||||
},
|
||||
]);
|
||||
store.setSelectedCategory('7');
|
||||
|
||||
expect(store.getSelectedCategory()).toEqual({
|
||||
category_id: '7',
|
||||
category_name: 'Drama',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes content failures into empty collections instead of undefined state', async () => {
|
||||
dataService.sendIpcEvent.mockRejectedValue(
|
||||
new Error('get_ordered_list failed')
|
||||
);
|
||||
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCategories('itv', [
|
||||
{
|
||||
category_id: '5',
|
||||
category_name: 'News',
|
||||
},
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(
|
||||
() => dataService.sendIpcEvent.mock.calls.length > 0
|
||||
);
|
||||
await flushResources();
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'itv',
|
||||
category: '5',
|
||||
genre: '5',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(store.getPaginatedContent()).toEqual([]);
|
||||
expect(store.itvChannels()).toEqual([]);
|
||||
expect(store.hasMoreChannels()).toBe(false);
|
||||
expect(store.isPaginatedContentFailed()).toBeInstanceOf(Error);
|
||||
expect((store.isPaginatedContentFailed() as Error).message).toBe(
|
||||
'get_ordered_list failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale content responses after the selected page changes', async () => {
|
||||
const pendingByPage = new Map<
|
||||
number,
|
||||
ReturnType<typeof createDeferred<unknown>>[]
|
||||
>();
|
||||
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event: unknown, payload: { params?: { p?: number } }) => {
|
||||
const page = Number(payload.params?.p);
|
||||
const deferred = createDeferred<unknown>();
|
||||
const pending = pendingByPage.get(page) ?? [];
|
||||
pending.push(deferred);
|
||||
pendingByPage.set(page, pending);
|
||||
return deferred.promise;
|
||||
}
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCategories('vod', [
|
||||
{
|
||||
category_id: '5',
|
||||
category_name: 'Movies',
|
||||
},
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => pendingByPage.get(1)?.length === 1);
|
||||
pendingByPage
|
||||
.get(1)?.[0]
|
||||
.resolve(createContentResponse('Page 1 initial'));
|
||||
await waitForCondition(
|
||||
() => store.getPaginatedContent()[0]?.name === 'Page 1 initial'
|
||||
);
|
||||
|
||||
store.setPage(1);
|
||||
await waitForCondition(() => pendingByPage.get(2)?.length === 1);
|
||||
|
||||
store.setPage(0);
|
||||
await waitForCondition(() => pendingByPage.get(1)?.length === 2);
|
||||
pendingByPage
|
||||
.get(1)?.[1]
|
||||
.resolve(createContentResponse('Page 1 restored'));
|
||||
await waitForCondition(
|
||||
() => store.getPaginatedContent()[0]?.name === 'Page 1 restored'
|
||||
);
|
||||
|
||||
pendingByPage
|
||||
.get(2)?.[0]
|
||||
.resolve(createContentResponse('Page 2 stale'));
|
||||
await flushResources();
|
||||
|
||||
expect(store.page()).toBe(0);
|
||||
expect(store.getPaginatedContent()[0]?.name).toBe('Page 1 restored');
|
||||
});
|
||||
});
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
import { computed, inject, resource } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService } from 'services';
|
||||
import {
|
||||
StalkerCategoryItem,
|
||||
StalkerContentItem,
|
||||
StalkerItvChannel,
|
||||
StalkerVodSource,
|
||||
} from '../../models';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import {
|
||||
ResourceState,
|
||||
StalkerCategorySliceContract,
|
||||
StalkerContentFeatureStoreContract,
|
||||
StalkerContentType,
|
||||
} from '../stalker-store.contracts';
|
||||
import {
|
||||
executeStalkerRequest,
|
||||
toStalkerContentItem,
|
||||
toStalkerItvChannel,
|
||||
} from '../utils';
|
||||
|
||||
/**
|
||||
* Content/categories/channels feature state.
|
||||
*/
|
||||
export interface StalkerContentState {
|
||||
totalCount: number;
|
||||
vodCategories: StalkerCategoryItem[];
|
||||
seriesCategories: StalkerCategoryItem[];
|
||||
itvCategories: StalkerCategoryItem[];
|
||||
hasMoreChannels: boolean;
|
||||
itvChannels: StalkerItvChannel[];
|
||||
paginatedContent: StalkerContentItem[];
|
||||
categoryError: unknown;
|
||||
contentError: unknown;
|
||||
}
|
||||
|
||||
const initialContentState: StalkerContentState = {
|
||||
totalCount: 0,
|
||||
vodCategories: [],
|
||||
seriesCategories: [],
|
||||
itvCategories: [],
|
||||
hasMoreChannels: false,
|
||||
itvChannels: [],
|
||||
paginatedContent: [],
|
||||
categoryError: null,
|
||||
contentError: null,
|
||||
};
|
||||
|
||||
interface StalkerCategoryResponseItem {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface StalkerCategoryResponse {
|
||||
js?: StalkerCategoryResponseItem[];
|
||||
}
|
||||
|
||||
interface StalkerOrderedListResponse {
|
||||
js?: {
|
||||
data?: StalkerVodSource[];
|
||||
total_items?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface StalkerContentResourceStoreContract extends StalkerContentFeatureStoreContract {
|
||||
categoryResource: ResourceState<StalkerCategoryItem[]>;
|
||||
getContentResource: ResourceState<StalkerContentItem[]>;
|
||||
}
|
||||
|
||||
function getCategoriesByType(
|
||||
store: StalkerCategorySliceContract,
|
||||
contentType: StalkerContentType
|
||||
): StalkerCategoryItem[] {
|
||||
switch (contentType) {
|
||||
case 'vod':
|
||||
return store.vodCategories();
|
||||
case 'series':
|
||||
return store.seriesCategories();
|
||||
case 'itv':
|
||||
return store.itvCategories();
|
||||
}
|
||||
}
|
||||
|
||||
function buildCategoryPatch(
|
||||
contentType: StalkerContentType,
|
||||
categories: StalkerCategoryItem[]
|
||||
): Partial<StalkerContentState> {
|
||||
switch (contentType) {
|
||||
case 'vod':
|
||||
return { vodCategories: categories };
|
||||
case 'series':
|
||||
return { seriesCategories: categories };
|
||||
case 'itv':
|
||||
return { itvCategories: categories };
|
||||
}
|
||||
}
|
||||
|
||||
function prependAllCategory(
|
||||
categories: StalkerCategoryItem[],
|
||||
translateService: TranslateService
|
||||
): StalkerCategoryItem[] {
|
||||
const allIndex = categories.findIndex(
|
||||
(category) => category.category_name.trim().toLowerCase() === 'all'
|
||||
);
|
||||
|
||||
if (allIndex > 0) {
|
||||
categories.unshift(categories.splice(allIndex, 1)[0]);
|
||||
return categories;
|
||||
}
|
||||
|
||||
if (
|
||||
allIndex === -1 &&
|
||||
categories.length > 0 &&
|
||||
!categories.some((category) => String(category.category_id) === '*')
|
||||
) {
|
||||
categories.unshift({
|
||||
category_name: translateService.instant('PORTALS.ALL_CATEGORIES'),
|
||||
category_id: '*',
|
||||
});
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
function buildEmptyContentPatch(
|
||||
contentType: StalkerContentType,
|
||||
error: unknown
|
||||
): Partial<StalkerContentState> {
|
||||
const patch: Partial<StalkerContentState> = {
|
||||
totalCount: 0,
|
||||
paginatedContent: [],
|
||||
contentError: error,
|
||||
};
|
||||
|
||||
if (contentType === 'itv') {
|
||||
patch.hasMoreChannels = false;
|
||||
patch.itvChannels = [];
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function withStalkerContent() {
|
||||
const logger = createLogger('withStalkerContent');
|
||||
|
||||
return signalStoreFeature(
|
||||
withState<StalkerContentState>(initialContentState),
|
||||
withProps(
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
translateService = inject(TranslateService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerContentResourceStoreContract;
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
};
|
||||
|
||||
return {
|
||||
categoryResource: resource({
|
||||
params: () => ({
|
||||
contentType: storeContext.selectedContentType(),
|
||||
currentPlaylist: storeContext.currentPlaylist(),
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
}): Promise<StalkerCategoryItem[]> => {
|
||||
if (!params.currentPlaylist) {
|
||||
patchState(store, { categoryError: null });
|
||||
return [];
|
||||
}
|
||||
|
||||
const cachedCategories = getCategoriesByType(
|
||||
store,
|
||||
params.contentType
|
||||
);
|
||||
if (cachedCategories.length > 0) {
|
||||
patchState(store, { categoryError: null });
|
||||
return cachedCategories;
|
||||
}
|
||||
|
||||
try {
|
||||
const response =
|
||||
await executeStalkerRequest<StalkerCategoryResponse>(
|
||||
requestDeps,
|
||||
params.currentPlaylist,
|
||||
{
|
||||
action: StalkerContentTypes[
|
||||
params.contentType
|
||||
].getCategoryAction,
|
||||
type: params.contentType,
|
||||
}
|
||||
);
|
||||
|
||||
if (!Array.isArray(response?.js)) {
|
||||
const invalidResponseError = new Error(
|
||||
'Invalid categories response'
|
||||
);
|
||||
logger.warn(
|
||||
'Invalid categories response',
|
||||
response
|
||||
);
|
||||
patchState(store, {
|
||||
...buildCategoryPatch(
|
||||
params.contentType,
|
||||
[]
|
||||
),
|
||||
categoryError: invalidResponseError,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const categories = prependAllCategory(
|
||||
response.js
|
||||
.map(
|
||||
(item): StalkerCategoryItem => ({
|
||||
category_name: item.title ?? '',
|
||||
category_id: String(item.id),
|
||||
})
|
||||
)
|
||||
.sort((left, right) =>
|
||||
left.category_name.localeCompare(
|
||||
right.category_name
|
||||
)
|
||||
),
|
||||
translateService
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
...buildCategoryPatch(
|
||||
params.contentType,
|
||||
categories
|
||||
),
|
||||
categoryError: null,
|
||||
});
|
||||
|
||||
return categories;
|
||||
} catch (error) {
|
||||
logger.warn('Error loading categories', {
|
||||
contentType: params.contentType,
|
||||
error,
|
||||
});
|
||||
patchState(store, {
|
||||
...buildCategoryPatch(
|
||||
params.contentType,
|
||||
[]
|
||||
),
|
||||
categoryError: error,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}),
|
||||
getContentResource: resource({
|
||||
params: () => ({
|
||||
contentType: storeContext.selectedContentType(),
|
||||
category: storeContext.selectedCategoryId(),
|
||||
search: storeContext.searchPhrase(),
|
||||
pageIndex: storeContext.page() + 1,
|
||||
currentPlaylist: storeContext.currentPlaylist(),
|
||||
availableCategoryCount: getCategoriesByType(
|
||||
store,
|
||||
storeContext.selectedContentType()
|
||||
).filter(
|
||||
(category) =>
|
||||
String(category.category_id) !== '*'
|
||||
).length,
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
}): Promise<StalkerContentItem[]> => {
|
||||
if (!params.category || params.category === '') {
|
||||
patchState(
|
||||
store,
|
||||
buildEmptyContentPatch(
|
||||
params.contentType,
|
||||
null
|
||||
)
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
params.category === '*' &&
|
||||
(params.contentType === 'vod' ||
|
||||
params.contentType === 'series') &&
|
||||
params.availableCategoryCount === 0
|
||||
) {
|
||||
patchState(
|
||||
store,
|
||||
buildEmptyContentPatch(
|
||||
params.contentType,
|
||||
null
|
||||
)
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const playlist = params.currentPlaylist;
|
||||
if (!playlist?.portalUrl) {
|
||||
patchState(
|
||||
store,
|
||||
buildEmptyContentPatch(
|
||||
params.contentType,
|
||||
null
|
||||
)
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const categoryParam = params.category || '*';
|
||||
const paramsPlaylistKey =
|
||||
params.currentPlaylist?._id ??
|
||||
params.currentPlaylist?.portalUrl ??
|
||||
null;
|
||||
const isCurrentRequest = (): boolean => {
|
||||
const currentPlaylist =
|
||||
storeContext.currentPlaylist();
|
||||
const currentPlaylistKey =
|
||||
currentPlaylist?._id ??
|
||||
currentPlaylist?.portalUrl ??
|
||||
null;
|
||||
|
||||
return (
|
||||
params.contentType ===
|
||||
storeContext.selectedContentType() &&
|
||||
params.category ===
|
||||
storeContext.selectedCategoryId() &&
|
||||
params.search ===
|
||||
storeContext.searchPhrase() &&
|
||||
params.pageIndex ===
|
||||
storeContext.page() + 1 &&
|
||||
paramsPlaylistKey === currentPlaylistKey
|
||||
);
|
||||
};
|
||||
const queryParams: Record<string, string | number> =
|
||||
{
|
||||
action: StalkerContentTypes[
|
||||
params.contentType
|
||||
].getContentAction,
|
||||
type: params.contentType,
|
||||
sortby: 'added',
|
||||
...(params.search !== ''
|
||||
? { search: params.search }
|
||||
: {}),
|
||||
p: params.pageIndex,
|
||||
};
|
||||
|
||||
if (params.contentType === 'vod') {
|
||||
queryParams['genre'] = '0';
|
||||
queryParams['category'] = categoryParam;
|
||||
} else if (params.contentType === 'series') {
|
||||
queryParams['category'] = categoryParam;
|
||||
} else {
|
||||
queryParams['category'] = categoryParam;
|
||||
queryParams['genre'] = categoryParam;
|
||||
}
|
||||
|
||||
try {
|
||||
patchState(store, {
|
||||
paginatedContent: [],
|
||||
contentError: null,
|
||||
});
|
||||
|
||||
const response =
|
||||
await executeStalkerRequest<StalkerOrderedListResponse>(
|
||||
requestDeps,
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
|
||||
if (!isCurrentRequest()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(response?.js?.data)) {
|
||||
const invalidResponseError = new Error(
|
||||
'Invalid response structure'
|
||||
);
|
||||
logger.warn(
|
||||
'Invalid response structure',
|
||||
response
|
||||
);
|
||||
patchState(store, {
|
||||
...buildEmptyContentPatch(
|
||||
params.contentType,
|
||||
invalidResponseError
|
||||
),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const newItems = response.js.data.map((item) =>
|
||||
toStalkerContentItem(
|
||||
item,
|
||||
playlist.portalUrl ?? ''
|
||||
)
|
||||
);
|
||||
|
||||
if (params.contentType === 'itv') {
|
||||
const channels =
|
||||
newItems.map(toStalkerItvChannel);
|
||||
const nextChannels =
|
||||
params.pageIndex === 1
|
||||
? channels
|
||||
: [
|
||||
...store.itvChannels(),
|
||||
...channels,
|
||||
];
|
||||
|
||||
patchState(store, {
|
||||
totalCount:
|
||||
response.js.total_items ?? 0,
|
||||
paginatedContent: newItems,
|
||||
contentError: null,
|
||||
itvChannels: nextChannels,
|
||||
hasMoreChannels:
|
||||
nextChannels.length <
|
||||
(response.js.total_items ?? 0),
|
||||
});
|
||||
} else {
|
||||
patchState(store, {
|
||||
totalCount:
|
||||
response.js.total_items ?? 0,
|
||||
paginatedContent: newItems,
|
||||
contentError: null,
|
||||
hasMoreChannels: false,
|
||||
});
|
||||
}
|
||||
|
||||
return newItems;
|
||||
} catch (error) {
|
||||
if (!isCurrentRequest()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.warn('Error loading content', {
|
||||
contentType: params.contentType,
|
||||
category: params.category,
|
||||
error,
|
||||
});
|
||||
patchState(
|
||||
store,
|
||||
buildEmptyContentPatch(
|
||||
params.contentType,
|
||||
error
|
||||
)
|
||||
);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
),
|
||||
withComputed((store) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerContentResourceStoreContract;
|
||||
|
||||
return {
|
||||
getTotalPages: computed(() =>
|
||||
Math.ceil(store.totalCount() / storeContext.limit())
|
||||
),
|
||||
getSelectedCategory: computed(() => {
|
||||
const categoryId = storeContext.selectedCategoryId();
|
||||
if (!categoryId) {
|
||||
return {
|
||||
id: 0,
|
||||
category_name: 'All Items',
|
||||
type: storeContext.selectedContentType(),
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = storeContext.selectedContentType();
|
||||
const categories = getCategoriesByType(store, contentType);
|
||||
|
||||
return (
|
||||
categories.find(
|
||||
(category) =>
|
||||
String(category.category_id) ===
|
||||
String(categoryId)
|
||||
) || {
|
||||
category_id: categoryId,
|
||||
category_name: '',
|
||||
type: contentType,
|
||||
}
|
||||
);
|
||||
}),
|
||||
getSelectedCategoryName: computed(() => {
|
||||
const selectedCategoryId =
|
||||
storeContext.selectedCategoryId();
|
||||
if (!selectedCategoryId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const category = getCategoriesByType(
|
||||
store,
|
||||
storeContext.selectedContentType()
|
||||
).find(
|
||||
(item) =>
|
||||
String(item.category_id) ===
|
||||
String(selectedCategoryId)
|
||||
);
|
||||
|
||||
return category?.category_name ?? '';
|
||||
}),
|
||||
getPaginatedContent: computed(() => store.paginatedContent()),
|
||||
isPaginatedContentLoading: computed(() =>
|
||||
storeContext.getContentResource.isLoading()
|
||||
),
|
||||
isPaginatedContentFailed: computed(() => store.contentError()),
|
||||
getCategoryResource: computed(() =>
|
||||
getCategoriesByType(
|
||||
store,
|
||||
storeContext.selectedContentType()
|
||||
)
|
||||
),
|
||||
isCategoryResourceLoading: computed(() =>
|
||||
storeContext.categoryResource.isLoading()
|
||||
),
|
||||
isCategoryResourceFailed: computed(() => store.categoryError()),
|
||||
};
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
setCategories(
|
||||
type: StalkerContentType,
|
||||
categories: StalkerCategoryItem[]
|
||||
) {
|
||||
patchState(store, buildCategoryPatch(type, categories));
|
||||
},
|
||||
resetCategories() {
|
||||
patchState(store, {
|
||||
vodCategories: [],
|
||||
seriesCategories: [],
|
||||
itvCategories: [],
|
||||
categoryError: null,
|
||||
});
|
||||
},
|
||||
setItvChannels(channels: StalkerItvChannel[]) {
|
||||
patchState(store, { itvChannels: channels });
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import { DataService } from 'services';
|
||||
import { EpgItem, Playlist } from 'shared-interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { withStalkerEpg } from './with-stalker-epg.feature';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const PLAYLIST = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Demo Stalker',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
importDate: '2026-04-11T00:00:00.000Z',
|
||||
lastUsage: '2026-04-11T00:00:00.000Z',
|
||||
portalUrl: 'http://demo.example/portal.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
} as Playlist;
|
||||
|
||||
const TestStalkerEpgStore = signalStore(
|
||||
withState({
|
||||
currentPlaylist: PLAYLIST,
|
||||
selectedItvId: '10001',
|
||||
}),
|
||||
withStalkerEpg()
|
||||
);
|
||||
|
||||
describe('withStalkerEpg', () => {
|
||||
let store: InstanceType<typeof TestStalkerEpgStore>;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
};
|
||||
let stalkerSessionService: {
|
||||
makeAuthenticatedRequest: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
stalkerSessionService = {
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestStalkerEpgStore,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: stalkerSessionService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestStalkerEpgStore);
|
||||
});
|
||||
|
||||
it('fetches fallback short EPG via get_short_epg', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: {
|
||||
data: [
|
||||
buildEntry('10001', 'Current Show', 1744365600, 1744367400),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await store.fetchChannelEpg('10001');
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
url: PLAYLIST.portalUrl,
|
||||
macAddress: PLAYLIST.macAddress,
|
||||
params: expect.objectContaining({
|
||||
action: 'get_short_epg',
|
||||
type: 'itv',
|
||||
ch_id: '10001',
|
||||
size: '10',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual([
|
||||
buildEpgItem('10001', 'Current Show', 1744365600, 1744367400),
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads bulk EPG once and projects selected-channel programs from the cache', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: {
|
||||
data: {
|
||||
'10001': [
|
||||
buildEntry('10001', 'Morning Show', 1744358400, 1744362000),
|
||||
buildEntry('10001', 'Current Show', 1744362000, 1744365600),
|
||||
],
|
||||
'10002': [
|
||||
buildEntry('10002', 'Other Channel', 1744362000, 1744365600),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await store.ensureBulkItvEpg(168);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: 'get_epg_info',
|
||||
type: 'itv',
|
||||
period: '168',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(store.bulkItvEpgLoaded()).toBe(true);
|
||||
expect(store.bulkItvEpgPlaylistId()).toBe('playlist-1');
|
||||
expect(store.bulkItvEpgPeriodHours()).toBe(168);
|
||||
expect(store.selectedItvEpgPrograms()).toEqual([
|
||||
buildProgram('10001', 'Morning Show', 1744358400, 1744362000),
|
||||
buildProgram('10001', 'Current Show', 1744362000, 1744365600),
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats bulk EPG failures as loaded-empty cache so callers can fallback to short EPG', async () => {
|
||||
dataService.sendIpcEvent.mockRejectedValue(new Error('unsupported'));
|
||||
|
||||
await store.ensureBulkItvEpg(168);
|
||||
|
||||
expect(store.bulkItvEpgLoaded()).toBe(true);
|
||||
expect(store.bulkItvEpgByChannel()).toEqual({});
|
||||
expect(store.selectedItvEpgPrograms()).toEqual([]);
|
||||
expect(store.isLoadingBulkItvEpg()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function buildEntry(
|
||||
channelId: string,
|
||||
title: string,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
) {
|
||||
return {
|
||||
id: `${channelId}-${startTimestamp}`,
|
||||
ch_id: channelId,
|
||||
name: title,
|
||||
descr: `${title} description`,
|
||||
start: new Date(startTimestamp * 1000).toISOString(),
|
||||
stop: new Date(stopTimestamp * 1000).toISOString(),
|
||||
start_timestamp: startTimestamp,
|
||||
stop_timestamp: stopTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEpgItem(
|
||||
channelId: string,
|
||||
title: string,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): EpgItem {
|
||||
return {
|
||||
id: `${channelId}-${startTimestamp}`,
|
||||
epg_id: '',
|
||||
title,
|
||||
description: `${title} description`,
|
||||
lang: '',
|
||||
start: new Date(startTimestamp * 1000).toISOString(),
|
||||
end: new Date(stopTimestamp * 1000).toISOString(),
|
||||
stop: new Date(stopTimestamp * 1000).toISOString(),
|
||||
channel_id: channelId,
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
};
|
||||
}
|
||||
|
||||
function buildProgram(
|
||||
channelId: string,
|
||||
title: string,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
) {
|
||||
return {
|
||||
start: new Date(startTimestamp * 1000).toISOString(),
|
||||
stop: new Date(stopTimestamp * 1000).toISOString(),
|
||||
channel: channelId,
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService } from 'services';
|
||||
import { EpgItem, EpgProgram, StalkerPortalActions } from 'shared-interfaces';
|
||||
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerEpgFeatureStoreContract } from '../stalker-store.contracts';
|
||||
import { executeStalkerRequest } from '../utils';
|
||||
|
||||
interface StalkerEpgEntry {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
descr?: string;
|
||||
category?: string;
|
||||
time?: string;
|
||||
time_to?: string;
|
||||
start?: string;
|
||||
stop?: string;
|
||||
ch_id?: string | number;
|
||||
start_timestamp?: string | number;
|
||||
stop_timestamp?: string | number;
|
||||
}
|
||||
|
||||
type StalkerBulkEpgValue =
|
||||
| StalkerEpgEntry[]
|
||||
| {
|
||||
data?: StalkerEpgEntry[];
|
||||
epg?: StalkerEpgEntry[];
|
||||
items?: StalkerEpgEntry[];
|
||||
};
|
||||
|
||||
interface StalkerEpgResponse {
|
||||
js?:
|
||||
| StalkerEpgEntry[]
|
||||
| {
|
||||
data?: StalkerEpgEntry[] | Record<string, StalkerBulkEpgValue>;
|
||||
}
|
||||
| Record<string, StalkerBulkEpgValue>;
|
||||
}
|
||||
|
||||
export interface StalkerEpgState {
|
||||
bulkItvEpgByChannel: Record<string, EpgProgram[]>;
|
||||
bulkItvEpgPlaylistId: string | null;
|
||||
bulkItvEpgPeriodHours: number | null;
|
||||
bulkItvEpgLoaded: boolean;
|
||||
isLoadingBulkItvEpg: boolean;
|
||||
}
|
||||
|
||||
const initialEpgState: StalkerEpgState = {
|
||||
bulkItvEpgByChannel: {},
|
||||
bulkItvEpgPlaylistId: null,
|
||||
bulkItvEpgPeriodHours: null,
|
||||
bulkItvEpgLoaded: false,
|
||||
isLoadingBulkItvEpg: false,
|
||||
};
|
||||
|
||||
const ACTIVE_EPG_FALLBACK_SIZE = 10;
|
||||
|
||||
/**
|
||||
* EPG concern methods.
|
||||
*/
|
||||
export function withStalkerEpg() {
|
||||
const logger = createLogger('withStalkerEpg');
|
||||
|
||||
return signalStoreFeature(
|
||||
withState<StalkerEpgState>(initialEpgState),
|
||||
withComputed((store) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerEpgFeatureStoreContract;
|
||||
|
||||
return {
|
||||
selectedItvEpgPrograms: computed(() => {
|
||||
const selectedId = storeContext.selectedItvId();
|
||||
if (!selectedId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
store.bulkItvEpgByChannel()[
|
||||
normalizeStalkerEntityId(selectedId)
|
||||
] ?? []
|
||||
);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerEpgFeatureStoreContract;
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
};
|
||||
|
||||
const requestEpg = async (
|
||||
playlist: NonNullable<
|
||||
ReturnType<
|
||||
StalkerEpgFeatureStoreContract['currentPlaylist']
|
||||
>
|
||||
>,
|
||||
queryParams: Record<string, string>
|
||||
): Promise<StalkerEpgResponse> => {
|
||||
return executeStalkerRequest<StalkerEpgResponse>(
|
||||
requestDeps,
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
};
|
||||
|
||||
const fetchShortEpg = async (
|
||||
channelId: number | string,
|
||||
size: number
|
||||
): Promise<EpgItem[]> => {
|
||||
const playlist = storeContext.currentPlaylist();
|
||||
if (!playlist) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await requestEpg(playlist, {
|
||||
action: StalkerPortalActions.GetShortEpg,
|
||||
type: 'itv',
|
||||
ch_id: String(channelId),
|
||||
size: String(size),
|
||||
});
|
||||
|
||||
return extractShortEpgEntries(response)
|
||||
.map((item) => toEpgItem(item, channelId))
|
||||
.filter((item) => item.start && item.stop)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
getProgramTimestampMs(
|
||||
left.start,
|
||||
left.start_timestamp
|
||||
) -
|
||||
getProgramTimestampMs(
|
||||
right.start,
|
||||
right.start_timestamp
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
async fetchChannelEpg(
|
||||
channelId: number | string,
|
||||
size = ACTIVE_EPG_FALLBACK_SIZE
|
||||
): Promise<EpgItem[]> {
|
||||
try {
|
||||
return await fetchShortEpg(channelId, size);
|
||||
} catch (error) {
|
||||
logger.error('Error loading short Stalker EPG', {
|
||||
channelId,
|
||||
size,
|
||||
error,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async ensureBulkItvEpg(periodHours = 168): Promise<void> {
|
||||
const playlist = storeContext.currentPlaylist();
|
||||
if (!playlist?._id) {
|
||||
patchState(store, initialEpgState);
|
||||
return;
|
||||
}
|
||||
|
||||
const playlistId = String(playlist._id);
|
||||
const shouldReuseCache =
|
||||
store.bulkItvEpgLoaded() &&
|
||||
store.bulkItvEpgPlaylistId() === playlistId &&
|
||||
store.bulkItvEpgPeriodHours() === periodHours;
|
||||
|
||||
if (shouldReuseCache || store.isLoadingBulkItvEpg()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.bulkItvEpgPlaylistId() !== playlistId) {
|
||||
patchState(store, initialEpgState);
|
||||
}
|
||||
|
||||
patchState(store, {
|
||||
isLoadingBulkItvEpg: true,
|
||||
bulkItvEpgPlaylistId: playlistId,
|
||||
bulkItvEpgPeriodHours: periodHours,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await requestEpg(playlist, {
|
||||
action: StalkerPortalActions.GetEpgInfo,
|
||||
type: 'itv',
|
||||
period: String(periodHours),
|
||||
});
|
||||
const selectedChannelId =
|
||||
storeContext.selectedItvId() ?? null;
|
||||
const bulkPrograms = extractBulkEpgByChannel(
|
||||
response,
|
||||
selectedChannelId
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
bulkItvEpgByChannel: bulkPrograms,
|
||||
bulkItvEpgLoaded: true,
|
||||
isLoadingBulkItvEpg: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Bulk Stalker EPG unavailable', error);
|
||||
patchState(store, {
|
||||
bulkItvEpgByChannel: {},
|
||||
bulkItvEpgLoaded: true,
|
||||
isLoadingBulkItvEpg: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearBulkItvEpgCache(): void {
|
||||
patchState(store, initialEpgState);
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function extractShortEpgEntries(
|
||||
response: StalkerEpgResponse
|
||||
): StalkerEpgEntry[] {
|
||||
if (Array.isArray(response?.js)) {
|
||||
return response.js;
|
||||
}
|
||||
|
||||
const data = response?.js?.data;
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
function extractBulkEpgByChannel(
|
||||
response: StalkerEpgResponse,
|
||||
selectedChannelId?: string | null
|
||||
): Record<string, EpgProgram[]> {
|
||||
const raw = response?.js;
|
||||
const groupedEntries: Record<string, StalkerEpgEntry[]> = {};
|
||||
|
||||
const appendEntries = (
|
||||
channelId: string | number | null | undefined,
|
||||
entries: StalkerEpgEntry[]
|
||||
) => {
|
||||
const normalizedChannelId = normalizeOptionalEntityId(channelId);
|
||||
if (!normalizedChannelId || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
groupedEntries[normalizedChannelId] = entries;
|
||||
};
|
||||
|
||||
const groupArrayEntries = (entries: StalkerEpgEntry[]) => {
|
||||
for (const entry of entries) {
|
||||
const entryChannelId = entry.ch_id ?? selectedChannelId ?? null;
|
||||
const normalizedChannelId =
|
||||
normalizeOptionalEntityId(entryChannelId);
|
||||
if (!normalizedChannelId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groupedEntries[normalizedChannelId] ??= [];
|
||||
groupedEntries[normalizedChannelId].push(entry);
|
||||
}
|
||||
};
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
groupArrayEntries(raw);
|
||||
} else {
|
||||
const rawData =
|
||||
raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw;
|
||||
|
||||
if (Array.isArray(rawData)) {
|
||||
groupArrayEntries(rawData);
|
||||
} else if (rawData && typeof rawData === 'object') {
|
||||
for (const [channelId, value] of Object.entries(rawData)) {
|
||||
appendEntries(channelId, extractEntriesFromBulkValue(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPrograms: Record<string, EpgProgram[]> = {};
|
||||
|
||||
for (const [channelId, entries] of Object.entries(groupedEntries)) {
|
||||
const programs = entriesToPrograms(entries, channelId);
|
||||
if (programs.length > 0) {
|
||||
normalizedPrograms[channelId] = programs;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedPrograms;
|
||||
}
|
||||
|
||||
function extractEntriesFromBulkValue(value: unknown): StalkerEpgEntry[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const recordValue = value as {
|
||||
data?: StalkerEpgEntry[];
|
||||
epg?: StalkerEpgEntry[];
|
||||
items?: StalkerEpgEntry[];
|
||||
};
|
||||
|
||||
if (Array.isArray(recordValue.data)) {
|
||||
return recordValue.data;
|
||||
}
|
||||
|
||||
if (Array.isArray(recordValue.epg)) {
|
||||
return recordValue.epg;
|
||||
}
|
||||
|
||||
if (Array.isArray(recordValue.items)) {
|
||||
return recordValue.items;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function entriesToPrograms(
|
||||
entries: StalkerEpgEntry[],
|
||||
fallbackChannelId: string | number
|
||||
): EpgProgram[] {
|
||||
return entries
|
||||
.map((entry) => toEpgProgram(entry, fallbackChannelId))
|
||||
.filter((program): program is EpgProgram => program !== null)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
getProgramTimestampMs(left.start, left.startTimestamp) -
|
||||
getProgramTimestampMs(right.start, right.startTimestamp)
|
||||
);
|
||||
}
|
||||
|
||||
function toEpgItem(
|
||||
item: StalkerEpgEntry,
|
||||
fallbackChannelId: string | number
|
||||
): EpgItem {
|
||||
const startRaw = item.time ?? item.start ?? '';
|
||||
const stopRaw = item.time_to ?? item.stop ?? '';
|
||||
const startTimestamp = getProgramTimestampSeconds(
|
||||
startRaw,
|
||||
item.start_timestamp
|
||||
);
|
||||
const stopTimestamp = getProgramTimestampSeconds(
|
||||
stopRaw,
|
||||
item.stop_timestamp
|
||||
);
|
||||
const start = toIsoString(startRaw, startTimestamp);
|
||||
const stop = toIsoString(stopRaw, stopTimestamp);
|
||||
|
||||
return {
|
||||
id: String(item.id ?? ''),
|
||||
epg_id: '',
|
||||
title: item.name ?? '',
|
||||
description: item.descr ?? '',
|
||||
lang: '',
|
||||
start,
|
||||
end: stop,
|
||||
stop,
|
||||
channel_id: String(item.ch_id ?? fallbackChannelId),
|
||||
start_timestamp: startTimestamp !== null ? String(startTimestamp) : '',
|
||||
stop_timestamp: stopTimestamp !== null ? String(stopTimestamp) : '',
|
||||
};
|
||||
}
|
||||
|
||||
function toEpgProgram(
|
||||
item: StalkerEpgEntry,
|
||||
fallbackChannelId: string | number
|
||||
): EpgProgram | null {
|
||||
const epgItem = toEpgItem(item, fallbackChannelId);
|
||||
if (!epgItem.start || !epgItem.stop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start: epgItem.start,
|
||||
stop: epgItem.stop,
|
||||
channel: normalizeStalkerEntityId(epgItem.channel_id),
|
||||
title: epgItem.title,
|
||||
desc: epgItem.description || null,
|
||||
category: item.category ?? null,
|
||||
startTimestamp: parseInteger(epgItem.start_timestamp),
|
||||
stopTimestamp: parseInteger(epgItem.stop_timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
function toIsoString(
|
||||
rawValue: string,
|
||||
timestampSeconds: number | null
|
||||
): string {
|
||||
if (timestampSeconds !== null) {
|
||||
return new Date(timestampSeconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
const normalized = String(rawValue ?? '').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const candidate = normalized.includes('T')
|
||||
? normalized
|
||||
: normalized.replace(' ', 'T');
|
||||
const parsed = Date.parse(candidate);
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
|
||||
}
|
||||
|
||||
function getProgramTimestampSeconds(
|
||||
rawValue: string,
|
||||
timestampValue?: string | number | null
|
||||
): number | null {
|
||||
const parsedTimestamp = parseInteger(timestampValue);
|
||||
if (parsedTimestamp !== null && parsedTimestamp > 0) {
|
||||
return parsedTimestamp;
|
||||
}
|
||||
|
||||
const normalized = String(rawValue ?? '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = normalized.includes('T')
|
||||
? normalized
|
||||
: normalized.replace(' ', 'T');
|
||||
const parsedDate = Date.parse(candidate);
|
||||
|
||||
return Number.isFinite(parsedDate) ? Math.floor(parsedDate / 1000) : null;
|
||||
}
|
||||
|
||||
function getProgramTimestampMs(
|
||||
rawValue: string,
|
||||
timestampValue?: string | number | null
|
||||
): number {
|
||||
const timestampSeconds = getProgramTimestampSeconds(
|
||||
rawValue,
|
||||
timestampValue
|
||||
);
|
||||
if (timestampSeconds !== null) {
|
||||
return timestampSeconds * 1000;
|
||||
}
|
||||
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function parseInteger(value: unknown): number | null {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeOptionalEntityId(
|
||||
value: string | number | null | undefined
|
||||
): string | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeStalkerEntityId(value);
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { signalStoreFeature, withMethods } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
|
||||
import { StalkerSelectionStoreContract } from '../stalker-store.contracts';
|
||||
import {
|
||||
dispatchStalkerPlaylistMetaUpdate,
|
||||
resolveStalkerCategoryId,
|
||||
} from '../utils';
|
||||
|
||||
interface FavoritesStoreContext {
|
||||
currentPlaylist(): PlaylistMeta | undefined;
|
||||
selectedContentType(): StalkerSelectionStoreContract['selectedContentType'] extends () => infer T
|
||||
? T
|
||||
: string;
|
||||
}
|
||||
|
||||
type FavoritePayload = StalkerPortalItem & {
|
||||
stream_id?: string | number;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Favorites concern methods.
|
||||
*/
|
||||
export function withStalkerFavorites() {
|
||||
return signalStoreFeature(
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
playlistService = inject(PlaylistsService),
|
||||
snackBar = inject(MatSnackBar),
|
||||
translate = inject(TranslateService),
|
||||
ngrxStore = inject(Store)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
FavoritesStoreContext;
|
||||
return {
|
||||
addToFavorites(item: FavoritePayload, onDone?: () => void) {
|
||||
const portalId = storeContext.currentPlaylist()?._id;
|
||||
if (!portalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
playlistService
|
||||
.addPortalFavorite(portalId, {
|
||||
...item,
|
||||
category_id: resolveStalkerCategoryId(
|
||||
item.category_id,
|
||||
storeContext.selectedContentType()
|
||||
),
|
||||
added_at: Date.now(),
|
||||
id: item.stream_id ?? item.id,
|
||||
})
|
||||
.subscribe((updatedPlaylist) => {
|
||||
dispatchStalkerPlaylistMetaUpdate(
|
||||
ngrxStore,
|
||||
portalId,
|
||||
{
|
||||
favorites: updatedPlaylist?.favorites,
|
||||
}
|
||||
);
|
||||
snackBar.open(
|
||||
translate.instant(
|
||||
'PORTALS.ADDED_TO_FAVORITES'
|
||||
),
|
||||
undefined,
|
||||
{
|
||||
duration: 1000,
|
||||
}
|
||||
);
|
||||
onDone?.();
|
||||
});
|
||||
},
|
||||
removeFromFavorites(
|
||||
favoriteId: string,
|
||||
onDone?: () => void
|
||||
) {
|
||||
const portalId = storeContext.currentPlaylist()?._id;
|
||||
if (!portalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
playlistService
|
||||
.removeFromPortalFavorites(portalId, favoriteId)
|
||||
.subscribe((updatedPlaylist) => {
|
||||
dispatchStalkerPlaylistMetaUpdate(
|
||||
ngrxStore,
|
||||
portalId,
|
||||
{
|
||||
favorites: updatedPlaylist?.favorites,
|
||||
}
|
||||
);
|
||||
snackBar.open(
|
||||
translate.instant(
|
||||
'PORTALS.REMOVED_FROM_FAVORITES'
|
||||
),
|
||||
undefined,
|
||||
{
|
||||
duration: 1000,
|
||||
}
|
||||
);
|
||||
onDone?.();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { withStalkerPlayer } from './with-stalker-player.feature';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
...jest.requireActual('@iptvnator/portal/shared/util'),
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const PLAYLIST = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Demo Stalker',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
importDate: '2026-04-14T00:00:00.000Z',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
const TestPlayerStore = signalStore(
|
||||
withState({
|
||||
currentPlaylist: PLAYLIST,
|
||||
selectedContentType: 'vod' as 'vod' | 'series' | 'itv',
|
||||
selectedItem: {
|
||||
id: '22',
|
||||
cmd: '/media/source_22.mpg',
|
||||
has_files: true,
|
||||
title: 'Original Title',
|
||||
category_id: 'vod',
|
||||
},
|
||||
}),
|
||||
withStalkerPlayer()
|
||||
);
|
||||
|
||||
describe('withStalkerPlayer', () => {
|
||||
let store: InstanceType<typeof TestPlayerStore>;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
};
|
||||
let playlistService: {
|
||||
addPortalRecentlyViewed: jest.Mock;
|
||||
};
|
||||
let ngrxStore: {
|
||||
dispatch: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
playlistService = {
|
||||
addPortalRecentlyViewed: jest.fn(() =>
|
||||
of({ recentlyViewed: [{ id: '22', title: 'Movie Title' }] })
|
||||
),
|
||||
};
|
||||
ngrxStore = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestPlayerStore,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistService,
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
getCachedToken: jest.fn(),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: ngrxStore,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestPlayerStore);
|
||||
});
|
||||
|
||||
it('falls back to file ids for VOD playback and persists recently viewed metadata', async () => {
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
data: [{ id: 77 }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
cmd: 'ffmpeg http://cdn.example/video_77.mpg',
|
||||
},
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Movie Title',
|
||||
'thumb.jpg'
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
movie_id: '22',
|
||||
p: '1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
cmd: '/media/file_77.mpg',
|
||||
type: 'vod',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
|
||||
PLAYLIST._id,
|
||||
expect.objectContaining({
|
||||
id: '22',
|
||||
title: 'Movie Title',
|
||||
category_id: 'vod',
|
||||
cover: 'thumb.jpg',
|
||||
added_at: expect.any(Number),
|
||||
})
|
||||
);
|
||||
expect(ngrxStore.dispatch).toHaveBeenCalled();
|
||||
expect(playback.streamUrl).toBe('http://cdn.example/video_77.mpg');
|
||||
expect(playback.contentInfo).toEqual({
|
||||
playlistId: PLAYLIST._id,
|
||||
contentXtreamId: 22,
|
||||
contentType: 'vod',
|
||||
seriesXtreamId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { signalStoreFeature, withMethods } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYER, createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
ResolvedPortalPlayback,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildStalkerExternalPlaybackHeaders,
|
||||
getStalkerPortalOrigin,
|
||||
isCrossOriginStalkerStream,
|
||||
STALKER_MAG_USER_AGENT,
|
||||
} from '../../stalker-live-playback.utils';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import {
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
} from '../../stalker-vod.utils';
|
||||
import {
|
||||
StalkerPlayerFeatureStoreContract,
|
||||
StalkerRecentlyViewedItem,
|
||||
} from '../stalker-store.contracts';
|
||||
import {
|
||||
buildStalkerRecentlyViewedPayload,
|
||||
dispatchStalkerPlaylistMetaUpdate,
|
||||
fetchStalkerExpireDate,
|
||||
fetchStalkerMovieFileId,
|
||||
fetchStalkerPlaybackLink,
|
||||
shouldResolveMovieFileId,
|
||||
} from '../utils';
|
||||
|
||||
type StalkerPlayableItem = StalkerPortalItem & {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Playback/link/player concern methods.
|
||||
*/
|
||||
export function withStalkerPlayer() {
|
||||
const logger = createLogger('withStalkerPlayer');
|
||||
|
||||
return signalStoreFeature(
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
playlistService = inject(PlaylistsService),
|
||||
playerService = inject(PORTAL_PLAYER),
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
snackBar = inject(MatSnackBar),
|
||||
translate = inject(TranslateService),
|
||||
ngrxStore = inject(Store)
|
||||
) => {
|
||||
const storeState = store as typeof store &
|
||||
StalkerPlayerFeatureStoreContract;
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
};
|
||||
|
||||
const createRequestPlaylist = (
|
||||
portalUrl: string,
|
||||
macAddress: string
|
||||
): PlaylistMeta => {
|
||||
const playlist = storeState.currentPlaylist();
|
||||
|
||||
return {
|
||||
...playlist,
|
||||
_id: playlist?._id ?? 'stalker-player',
|
||||
title: playlist?.title ?? 'Stalker Portal',
|
||||
count: playlist?.count ?? 0,
|
||||
autoRefresh: playlist?.autoRefresh ?? false,
|
||||
importDate: playlist?.importDate ?? '',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
};
|
||||
};
|
||||
|
||||
const persistRecentlyViewed = (
|
||||
playlistId: string,
|
||||
recentItem: StalkerRecentlyViewedItem
|
||||
): void => {
|
||||
playlistService
|
||||
.addPortalRecentlyViewed(playlistId, recentItem)
|
||||
.subscribe((updatedPlaylist) => {
|
||||
dispatchStalkerPlaylistMetaUpdate(
|
||||
ngrxStore,
|
||||
playlistId,
|
||||
{
|
||||
recentlyViewed:
|
||||
updatedPlaylist?.recentlyViewed,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const buildRecentlyViewedItem = (
|
||||
item: StalkerPlayableItem,
|
||||
cmd?: string,
|
||||
cover?: string,
|
||||
title?: string
|
||||
): StalkerRecentlyViewedItem =>
|
||||
buildStalkerRecentlyViewedPayload(
|
||||
{
|
||||
...item,
|
||||
cmd,
|
||||
cover,
|
||||
title,
|
||||
},
|
||||
storeState.selectedContentType()
|
||||
);
|
||||
|
||||
const recordRecentlyViewed = (
|
||||
item: StalkerPlayableItem | null | undefined,
|
||||
cmd?: string,
|
||||
cover?: string,
|
||||
title?: string
|
||||
): void => {
|
||||
const playlistId = storeState.currentPlaylist()?._id;
|
||||
if (!item || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recentItem = buildRecentlyViewedItem(
|
||||
item,
|
||||
cmd,
|
||||
cover,
|
||||
title
|
||||
);
|
||||
|
||||
if (typeof storeState.addToRecentlyViewed === 'function') {
|
||||
storeState.addToRecentlyViewed(recentItem);
|
||||
return;
|
||||
}
|
||||
|
||||
persistRecentlyViewed(playlistId, recentItem);
|
||||
};
|
||||
|
||||
const resolveVodPlaybackInternal = async (
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
episodeNum?: number,
|
||||
episodeId?: number,
|
||||
startTime?: number
|
||||
): Promise<ResolvedPortalPlayback> => {
|
||||
const item = storeState.selectedItem() as
|
||||
| StalkerPlayableItem
|
||||
| null
|
||||
| undefined;
|
||||
let cmdToUse = cmd ?? item?.cmd;
|
||||
|
||||
if (!cmdToUse) {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
|
||||
const playlist = storeState.currentPlaylist();
|
||||
if (!playlist?.portalUrl || !playlist.macAddress) {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
|
||||
if (shouldResolveMovieFileId(item, cmdToUse)) {
|
||||
const itemId = normalizeStalkerEntityId(item?.id);
|
||||
const fileId = await fetchStalkerMovieFileId(
|
||||
requestDeps,
|
||||
playlist,
|
||||
itemId
|
||||
);
|
||||
if (fileId) {
|
||||
cmdToUse = `/media/file_${fileId}.mpg`;
|
||||
}
|
||||
}
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(
|
||||
requestDeps,
|
||||
{
|
||||
playlist,
|
||||
selectedContentType:
|
||||
storeState.selectedContentType(),
|
||||
cmd: cmdToUse,
|
||||
series: episodeNum,
|
||||
}
|
||||
);
|
||||
|
||||
recordRecentlyViewed(item, cmd, thumbnail, title);
|
||||
|
||||
const isEpisode =
|
||||
episodeNum !== undefined || episodeId !== undefined;
|
||||
const selectedItemId =
|
||||
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
|
||||
|
||||
return {
|
||||
streamUrl,
|
||||
title: title ?? '',
|
||||
thumbnail,
|
||||
startTime,
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
contentInfo: {
|
||||
playlistId: playlist._id,
|
||||
contentXtreamId:
|
||||
isEpisode && episodeId
|
||||
? episodeId
|
||||
: selectedItemId,
|
||||
contentType: isEpisode ? 'episode' : 'vod',
|
||||
seriesXtreamId: isEpisode
|
||||
? selectedItemId
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const resolveItvPlaybackInternal = async (
|
||||
item: StalkerPlayableItem
|
||||
): Promise<ResolvedPortalPlayback> => {
|
||||
const playlist = storeState.currentPlaylist();
|
||||
if (
|
||||
!playlist?.portalUrl ||
|
||||
!playlist.macAddress ||
|
||||
!item.cmd
|
||||
) {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(
|
||||
requestDeps,
|
||||
{
|
||||
playlist,
|
||||
selectedContentType:
|
||||
storeState.selectedContentType(),
|
||||
cmd: item.cmd,
|
||||
forcedContentType: 'itv',
|
||||
}
|
||||
);
|
||||
|
||||
const token = stalkerSession.getCachedToken(playlist._id);
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
playlist,
|
||||
token,
|
||||
streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
|
||||
recordRecentlyViewed(
|
||||
item,
|
||||
item.cmd,
|
||||
item.logo ?? item.cover,
|
||||
item.o_name || item.name || item.title
|
||||
);
|
||||
|
||||
return {
|
||||
streamUrl,
|
||||
title: item.o_name || item.name || item.title || '',
|
||||
thumbnail: item.logo ?? item.cover ?? null,
|
||||
headers,
|
||||
userAgent:
|
||||
headers['User-Agent'] ||
|
||||
playlist.userAgent ||
|
||||
STALKER_MAG_USER_AGENT,
|
||||
referer: crossOriginStream
|
||||
? undefined
|
||||
: playlist.referrer || portalOrigin,
|
||||
origin: crossOriginStream
|
||||
? undefined
|
||||
: playlist.origin || portalOrigin,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async fetchLinkToPlay(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string,
|
||||
series?: number
|
||||
) {
|
||||
return fetchStalkerPlaybackLink(requestDeps, {
|
||||
playlist: createRequestPlaylist(
|
||||
portalUrl,
|
||||
macAddress
|
||||
),
|
||||
selectedContentType:
|
||||
storeState.selectedContentType(),
|
||||
cmd,
|
||||
series,
|
||||
});
|
||||
},
|
||||
async getExpireDate() {
|
||||
const playlist = storeState.currentPlaylist();
|
||||
if (!playlist) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchStalkerExpireDate(
|
||||
requestDeps,
|
||||
playlist
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch expire date', error);
|
||||
return 'Error fetching data';
|
||||
}
|
||||
},
|
||||
async fetchMovieFileId(
|
||||
movieId: string
|
||||
): Promise<string | null> {
|
||||
const playlist = storeState.currentPlaylist();
|
||||
if (!playlist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fetchStalkerMovieFileId(
|
||||
requestDeps,
|
||||
playlist,
|
||||
movieId
|
||||
);
|
||||
},
|
||||
async resolveVodPlayback(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
episodeNum?: number,
|
||||
episodeId?: number,
|
||||
startTime?: number
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
return resolveVodPlaybackInternal(
|
||||
cmd,
|
||||
title,
|
||||
thumbnail,
|
||||
episodeNum,
|
||||
episodeId,
|
||||
startTime
|
||||
);
|
||||
},
|
||||
async resolveItvPlayback(
|
||||
item: StalkerPlayableItem
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
return resolveItvPlaybackInternal(item);
|
||||
},
|
||||
async createLinkToPlayVod(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
episodeNum?: number,
|
||||
episodeId?: number,
|
||||
startTime?: number
|
||||
) {
|
||||
try {
|
||||
const playback = await resolveVodPlaybackInternal(
|
||||
cmd,
|
||||
title,
|
||||
thumbnail,
|
||||
episodeNum,
|
||||
episodeId,
|
||||
startTime
|
||||
);
|
||||
|
||||
void playerService.openResolvedPlayback(
|
||||
playback,
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get playback URL', error);
|
||||
const errorMessage =
|
||||
error instanceof Error &&
|
||||
error.message === 'nothing_to_play'
|
||||
? translate.instant(
|
||||
'PORTALS.CONTENT_NOT_AVAILABLE'
|
||||
)
|
||||
: translate.instant(
|
||||
'PORTALS.PLAYBACK_ERROR'
|
||||
);
|
||||
|
||||
snackBar.open(errorMessage, undefined, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { inject } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService } from 'services';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { toStalkerSessionPlaylist } from '../utils';
|
||||
|
||||
type StalkerPortalWindow = Window & {
|
||||
electron?: {
|
||||
dbCreatePlaylist: (playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
macAddress: string;
|
||||
url: string;
|
||||
type: 'stalker';
|
||||
}) => Promise<unknown>;
|
||||
dbGetPlaylist: (playlistId: string) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Portal/session state and methods.
|
||||
*/
|
||||
export interface StalkerPortalState {
|
||||
currentPlaylist: PlaylistMeta | undefined;
|
||||
}
|
||||
|
||||
const initialPortalState: StalkerPortalState = {
|
||||
currentPlaylist: undefined,
|
||||
};
|
||||
|
||||
export function withStalkerPortal() {
|
||||
const logger = createLogger('withStalkerPortal');
|
||||
return signalStoreFeature(
|
||||
withState<StalkerPortalState>(initialPortalState),
|
||||
withProps(
|
||||
(
|
||||
_store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => ({
|
||||
/**
|
||||
* Helper to make stalker requests with automatic token handling
|
||||
*/
|
||||
async makeStalkerRequest(
|
||||
playlist: PlaylistMeta,
|
||||
params: Record<string, string | number>
|
||||
) {
|
||||
// Get token if it's a full stalker portal
|
||||
let token: string | undefined;
|
||||
let serialNumber: string | undefined;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
try {
|
||||
const result = await stalkerSession.ensureToken(
|
||||
toStalkerSessionPlaylist(playlist)
|
||||
);
|
||||
token = result.token ?? undefined;
|
||||
serialNumber = playlist.stalkerSerialNumber;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get stalker token', error);
|
||||
}
|
||||
}
|
||||
|
||||
return dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
});
|
||||
},
|
||||
})
|
||||
),
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => ({
|
||||
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
|
||||
stalkerSession.setActiveWatchdogPlaylist(
|
||||
playlist
|
||||
? toStalkerSessionPlaylist(playlist)
|
||||
: undefined
|
||||
);
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
|
||||
// Ensure Stalker playlist exists in SQLite for playback positions
|
||||
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
|
||||
if (
|
||||
playlist &&
|
||||
dataService.isElectron &&
|
||||
playlist._id &&
|
||||
playlist.macAddress &&
|
||||
playlist.portalUrl
|
||||
) {
|
||||
try {
|
||||
const electronApi = (window as StalkerPortalWindow)
|
||||
.electron;
|
||||
if (!electronApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playlistId = String(playlist._id);
|
||||
// Check if playlist exists in SQLite
|
||||
const existing =
|
||||
await electronApi.dbGetPlaylist(playlistId);
|
||||
if (!existing) {
|
||||
// Create playlist in SQLite
|
||||
await electronApi.dbCreatePlaylist({
|
||||
id: playlistId,
|
||||
name: playlist.title || '',
|
||||
macAddress: playlist.macAddress || '',
|
||||
url: playlist.portalUrl || '',
|
||||
type: 'stalker',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Error syncing Stalker playlist to SQLite',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { signalStoreFeature, withMethods } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerContentType } from '../stalker-store.contracts';
|
||||
import {
|
||||
buildStalkerRecentlyViewedPayload,
|
||||
dispatchStalkerPlaylistMetaUpdate,
|
||||
} from '../utils';
|
||||
|
||||
interface RecentStoreContext {
|
||||
currentPlaylist(): PlaylistMeta | undefined;
|
||||
selectedContentType(): StalkerContentType;
|
||||
}
|
||||
|
||||
type RecentlyViewedPayload = StalkerPortalItem & {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recently-viewed concern methods.
|
||||
*/
|
||||
export function withStalkerRecent() {
|
||||
const logger = createLogger('withStalkerRecent');
|
||||
return signalStoreFeature(
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
playlistService = inject(PlaylistsService),
|
||||
ngrxStore = inject(Store)
|
||||
) => {
|
||||
const storeContext = store as typeof store & RecentStoreContext;
|
||||
|
||||
return {
|
||||
addToRecentlyViewed(item: RecentlyViewedPayload) {
|
||||
const portalId = storeContext.currentPlaylist()?._id;
|
||||
if (!portalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedContentType =
|
||||
storeContext.selectedContentType();
|
||||
const recentItem = buildStalkerRecentlyViewedPayload(
|
||||
item,
|
||||
selectedContentType
|
||||
);
|
||||
playlistService
|
||||
.addPortalRecentlyViewed(portalId, recentItem)
|
||||
.subscribe((updatedPlaylist) => {
|
||||
dispatchStalkerPlaylistMetaUpdate(
|
||||
ngrxStore,
|
||||
portalId,
|
||||
{
|
||||
recentlyViewed:
|
||||
updatedPlaylist?.recentlyViewed,
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
removeFromRecentlyViewed(
|
||||
itemId: string | number,
|
||||
onComplete?: () => void
|
||||
) {
|
||||
const portalId = storeContext.currentPlaylist()?._id;
|
||||
if (!portalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
playlistService
|
||||
.removeFromPortalRecentlyViewed(portalId, itemId)
|
||||
.subscribe({
|
||||
next: (updatedPlaylist) => {
|
||||
dispatchStalkerPlaylistMetaUpdate(
|
||||
ngrxStore,
|
||||
portalId,
|
||||
{
|
||||
recentlyViewed:
|
||||
updatedPlaylist?.recentlyViewed,
|
||||
}
|
||||
);
|
||||
onComplete?.();
|
||||
},
|
||||
error: (error) =>
|
||||
logger.error(
|
||||
'Failed to remove item from recently viewed',
|
||||
error
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore } from '@ngrx/signals';
|
||||
import { withStalkerSelection } from './with-stalker-selection.feature';
|
||||
|
||||
const TestSelectionStore = signalStore(withStalkerSelection());
|
||||
|
||||
describe('withStalkerSelection', () => {
|
||||
let store: InstanceType<typeof TestSelectionStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [TestSelectionStore],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestSelectionStore);
|
||||
});
|
||||
|
||||
it('resets paging when the selected category changes', () => {
|
||||
store.setPage(3);
|
||||
store.setSelectedCategory('series-7');
|
||||
|
||||
expect(store.selectedCategoryId()).toBe('series-7');
|
||||
expect(store.page()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps paging when the search phrase is unchanged', () => {
|
||||
store.setSearchPhrase('matrix');
|
||||
store.setPage(2);
|
||||
|
||||
store.setSearchPhrase('matrix');
|
||||
|
||||
expect(store.searchPhrase()).toBe('matrix');
|
||||
expect(store.page()).toBe(2);
|
||||
});
|
||||
|
||||
it('resets paging when the search phrase changes', () => {
|
||||
store.setPage(2);
|
||||
|
||||
store.setSearchPhrase('matrix');
|
||||
|
||||
expect(store.searchPhrase()).toBe('matrix');
|
||||
expect(store.page()).toBe(0);
|
||||
});
|
||||
|
||||
it('synchronizes entity ids when the selected item changes', () => {
|
||||
store.setSelectedItem({
|
||||
id: '55',
|
||||
name: 'Example',
|
||||
});
|
||||
|
||||
expect(store.selectedVodId()).toBe('55');
|
||||
expect(store.selectedSerialId()).toBe('55');
|
||||
expect(store.selectedItvId()).toBe('55');
|
||||
});
|
||||
});
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { StalkerVodSource } from '../../models';
|
||||
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
|
||||
|
||||
/**
|
||||
* Selection/pagination/search feature state.
|
||||
*/
|
||||
export interface StalkerSelectionState {
|
||||
selectedContentType: 'vod' | 'itv' | 'series';
|
||||
selectedCategoryId: string | null | undefined;
|
||||
selectedVodId: string | undefined;
|
||||
selectedSerialId: string | undefined;
|
||||
selectedItvId: string | undefined;
|
||||
limit: number;
|
||||
page: number;
|
||||
searchPhrase: string;
|
||||
selectedItem: StalkerVodSource | null | undefined;
|
||||
}
|
||||
|
||||
const initialSelectionState: StalkerSelectionState = {
|
||||
selectedContentType: 'vod',
|
||||
selectedCategoryId: undefined,
|
||||
selectedVodId: undefined,
|
||||
selectedSerialId: undefined,
|
||||
selectedItvId: undefined,
|
||||
limit: 14,
|
||||
page: 0,
|
||||
searchPhrase: '',
|
||||
selectedItem: undefined,
|
||||
};
|
||||
|
||||
export function withStalkerSelection() {
|
||||
return signalStoreFeature(
|
||||
withState<StalkerSelectionState>(initialSelectionState),
|
||||
withMethods((store) => ({
|
||||
setSelectedContentType(type: 'vod' | 'itv' | 'series') {
|
||||
patchState(store, { selectedContentType: type });
|
||||
},
|
||||
setSelectedCategory(id: string | number | null) {
|
||||
const newId =
|
||||
id !== null && id !== undefined ? String(id) : null;
|
||||
if (store.selectedCategoryId() === newId) {
|
||||
return;
|
||||
}
|
||||
patchState(store, {
|
||||
selectedCategoryId: newId,
|
||||
page: 0,
|
||||
});
|
||||
},
|
||||
setSelectedSerialId(id: string) {
|
||||
patchState(store, { selectedSerialId: id });
|
||||
},
|
||||
setSelectedVodId(id: string) {
|
||||
patchState(store, { selectedVodId: id });
|
||||
},
|
||||
setSelectedItvId(id: string) {
|
||||
patchState(store, { selectedItvId: id });
|
||||
},
|
||||
setLimit(limit: number) {
|
||||
if (store.limit() === limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
patchState(store, { limit });
|
||||
},
|
||||
setPage(page: number) {
|
||||
if (store.page() === page) {
|
||||
return;
|
||||
}
|
||||
|
||||
patchState(store, { page });
|
||||
},
|
||||
setSearchPhrase(phrase: string) {
|
||||
if (store.searchPhrase() === phrase) {
|
||||
return;
|
||||
}
|
||||
|
||||
patchState(store, { searchPhrase: phrase, page: 0 });
|
||||
},
|
||||
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
|
||||
const selectedIdRaw =
|
||||
selectedItem?.id !== undefined && selectedItem?.id !== null
|
||||
? selectedItem.id
|
||||
: undefined;
|
||||
const selectedId =
|
||||
selectedIdRaw !== undefined
|
||||
? normalizeStalkerEntityId(selectedIdRaw)
|
||||
: undefined;
|
||||
patchState(store, {
|
||||
selectedVodId: selectedId,
|
||||
selectedSerialId: selectedId,
|
||||
selectedItvId: selectedId,
|
||||
selectedItem,
|
||||
});
|
||||
},
|
||||
clearSelectedItem() {
|
||||
patchState(store, {
|
||||
selectedVodId: undefined,
|
||||
selectedSerialId: undefined,
|
||||
selectedItvId: undefined,
|
||||
selectedItem: undefined,
|
||||
});
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import { computed, inject, resource } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService } from 'services';
|
||||
import { StalkerPortalActions } from 'shared-interfaces';
|
||||
import {
|
||||
StalkerSeason,
|
||||
StalkerVodSeriesEpisode,
|
||||
StalkerVodSeriesSeason,
|
||||
} from '../../models';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts';
|
||||
import {
|
||||
executeStalkerRequest,
|
||||
sortByNumericValue,
|
||||
sortEpisodesByNumber,
|
||||
sortVodSeriesSeasonsByNumber,
|
||||
} from '../utils';
|
||||
|
||||
/**
|
||||
* Regular-series and VOD-series feature state.
|
||||
*/
|
||||
export interface StalkerSeriesState {
|
||||
/** For VOD items that are actually series (Ministra plugin is_series=1) */
|
||||
vodSeriesSeasons: StalkerVodSeriesSeason[];
|
||||
vodSeriesEpisodes: StalkerVodSeriesEpisode[];
|
||||
selectedVodSeriesSeasonId: string | undefined;
|
||||
}
|
||||
|
||||
const initialSeriesState: StalkerSeriesState = {
|
||||
vodSeriesSeasons: [],
|
||||
vodSeriesEpisodes: [],
|
||||
selectedVodSeriesSeasonId: undefined,
|
||||
};
|
||||
|
||||
interface StalkerSeriesResponse<T> {
|
||||
js?:
|
||||
| {
|
||||
data?: T[];
|
||||
}
|
||||
| T[];
|
||||
}
|
||||
|
||||
type StalkerSeriesStoreContext = StalkerSeriesFeatureStoreContract;
|
||||
|
||||
function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
|
||||
if (Array.isArray(response?.js)) {
|
||||
return response.js;
|
||||
}
|
||||
|
||||
if (Array.isArray(response?.js?.data)) {
|
||||
return response.js.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function toMovieId(value: unknown): string {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return raw.includes(':') ? raw.split(':')[0] : raw;
|
||||
}
|
||||
|
||||
export function withStalkerSeries() {
|
||||
const logger = createLogger('withStalkerSeries');
|
||||
|
||||
return signalStoreFeature(
|
||||
withState<StalkerSeriesState>(initialSeriesState),
|
||||
withProps(
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerSeriesStoreContext;
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
};
|
||||
|
||||
return {
|
||||
serialSeasonsResource: resource({
|
||||
params: () => ({
|
||||
itemId: storeContext.selectedSerialId(),
|
||||
currentPlaylist: storeContext.currentPlaylist(),
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
}): Promise<StalkerSeason[]> => {
|
||||
const playlist = params.currentPlaylist;
|
||||
const movieId = toMovieId(params.itemId);
|
||||
|
||||
if (!playlist || !movieId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await executeStalkerRequest<
|
||||
StalkerSeriesResponse<StalkerSeason>
|
||||
>(requestDeps, playlist, {
|
||||
action: StalkerContentTypes.series
|
||||
.getContentAction,
|
||||
type: 'series',
|
||||
movie_id: movieId,
|
||||
});
|
||||
|
||||
const seasons = extractSeriesItems(response);
|
||||
|
||||
if (seasons.length === 0) {
|
||||
logger.warn(
|
||||
'Invalid seasons response',
|
||||
response
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return sortByNumericValue(seasons);
|
||||
},
|
||||
}),
|
||||
vodSeriesSeasonsResource: resource({
|
||||
params: () => ({
|
||||
currentPlaylist: storeContext.currentPlaylist(),
|
||||
selectedItem: storeContext.selectedItem(),
|
||||
selectedContentType:
|
||||
storeContext.selectedContentType(),
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
}): Promise<StalkerVodSeriesSeason[]> => {
|
||||
const { currentPlaylist, selectedItem } = params;
|
||||
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource loader called',
|
||||
{
|
||||
item: selectedItem,
|
||||
isSeries: selectedItem?.is_series,
|
||||
currentPlaylist,
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
!currentPlaylist ||
|
||||
params.selectedContentType !== 'vod' ||
|
||||
!selectedItem ||
|
||||
selectedItem.id === undefined ||
|
||||
selectedItem.id === null ||
|
||||
!selectedItem.is_series
|
||||
) {
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource skipped - conditions not met'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await executeStalkerRequest<
|
||||
StalkerSeriesResponse<StalkerVodSeriesSeason>
|
||||
>(requestDeps, currentPlaylist, {
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
movie_id: selectedItem.id,
|
||||
p: '1',
|
||||
});
|
||||
|
||||
const seasonItems = extractSeriesItems(response);
|
||||
if (seasonItems.length === 0) {
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource - no response data'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource response data',
|
||||
seasonItems
|
||||
);
|
||||
|
||||
const seasons = seasonItems.filter(
|
||||
(item) => item.is_season === true
|
||||
);
|
||||
logger.debug(
|
||||
'vodSeriesSeasonsResource filtered seasons',
|
||||
seasons
|
||||
);
|
||||
|
||||
return sortVodSeriesSeasonsByNumber(seasons);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
),
|
||||
withComputed((store) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerSeriesStoreContext;
|
||||
|
||||
return {
|
||||
getSerialSeasonsResource: computed(() =>
|
||||
storeContext.serialSeasonsResource.value()
|
||||
),
|
||||
isSerialSeasonsLoading: computed(() =>
|
||||
storeContext.serialSeasonsResource.isLoading()
|
||||
),
|
||||
getVodSeriesSeasonsResource: computed(() =>
|
||||
storeContext.vodSeriesSeasonsResource.value()
|
||||
),
|
||||
isVodSeriesSeasonsLoading: computed(() =>
|
||||
storeContext.vodSeriesSeasonsResource.isLoading()
|
||||
),
|
||||
};
|
||||
}),
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
Pick<StalkerSeriesStoreContext, 'currentPlaylist'>;
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
};
|
||||
|
||||
return {
|
||||
async fetchVodSeriesEpisodes(
|
||||
videoId: string,
|
||||
seasonId: string
|
||||
): Promise<StalkerVodSeriesEpisode[]> {
|
||||
const playlist = storeContext.currentPlaylist();
|
||||
if (!playlist) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await executeStalkerRequest<
|
||||
StalkerSeriesResponse<StalkerVodSeriesEpisode>
|
||||
>(requestDeps, playlist, {
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
movie_id: videoId,
|
||||
season_id: seasonId,
|
||||
p: '1',
|
||||
});
|
||||
|
||||
const episodeItems = extractSeriesItems(response);
|
||||
if (episodeItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const episodes = sortEpisodesByNumber(
|
||||
episodeItems.filter(
|
||||
(item) => item.is_episode === true
|
||||
)
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
vodSeriesEpisodes: episodes,
|
||||
selectedVodSeriesSeasonId: seasonId,
|
||||
});
|
||||
|
||||
return episodes;
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './features';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
|
||||
import {
|
||||
StalkerCategoryItem,
|
||||
StalkerContentItem,
|
||||
StalkerSeason,
|
||||
StalkerVodSeriesSeason,
|
||||
StalkerVodSource,
|
||||
} from '../models';
|
||||
|
||||
export type StalkerContentType = 'vod' | 'series' | 'itv';
|
||||
|
||||
export interface ResourceState<T> {
|
||||
value(): T;
|
||||
isLoading(): boolean;
|
||||
error(): unknown;
|
||||
}
|
||||
|
||||
export interface StalkerPortalStoreContract {
|
||||
currentPlaylist(): PlaylistMeta | undefined;
|
||||
}
|
||||
|
||||
export interface StalkerSelectionStoreContract {
|
||||
selectedContentType(): StalkerContentType;
|
||||
selectedCategoryId(): string | null | undefined;
|
||||
selectedVodId(): string | undefined;
|
||||
selectedSerialId(): string | undefined;
|
||||
selectedItvId(): string | undefined;
|
||||
limit(): number;
|
||||
page(): number;
|
||||
searchPhrase(): string;
|
||||
selectedItem(): StalkerVodSource | null | undefined;
|
||||
}
|
||||
|
||||
export interface StalkerCategorySliceContract {
|
||||
vodCategories(): StalkerCategoryItem[];
|
||||
seriesCategories(): StalkerCategoryItem[];
|
||||
itvCategories(): StalkerCategoryItem[];
|
||||
}
|
||||
|
||||
export interface StalkerContentFeatureStoreContract
|
||||
extends
|
||||
StalkerPortalStoreContract,
|
||||
StalkerSelectionStoreContract,
|
||||
StalkerCategorySliceContract {
|
||||
getContentResource: ResourceState<StalkerContentItem[]>;
|
||||
categoryResource: ResourceState<StalkerCategoryItem[]>;
|
||||
}
|
||||
|
||||
export interface StalkerSeriesFeatureStoreContract
|
||||
extends
|
||||
StalkerPortalStoreContract,
|
||||
Pick<
|
||||
StalkerSelectionStoreContract,
|
||||
'selectedContentType' | 'selectedItem' | 'selectedSerialId'
|
||||
> {
|
||||
serialSeasonsResource: ResourceState<StalkerSeason[]>;
|
||||
vodSeriesSeasonsResource: ResourceState<StalkerVodSeriesSeason[]>;
|
||||
}
|
||||
|
||||
export interface StalkerRecentCallbackStoreContract {
|
||||
addToRecentlyViewed?: (item: StalkerRecentlyViewedItem) => void;
|
||||
}
|
||||
|
||||
export type StalkerRecentlyViewedItem = StalkerPortalItem & {
|
||||
id: string | number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export interface StalkerPlayerFeatureStoreContract
|
||||
extends
|
||||
StalkerPortalStoreContract,
|
||||
Pick<
|
||||
StalkerSelectionStoreContract,
|
||||
'selectedContentType' | 'selectedItem'
|
||||
>,
|
||||
StalkerRecentCallbackStoreContract {}
|
||||
|
||||
export interface StalkerEpgFeatureStoreContract
|
||||
extends
|
||||
StalkerPortalStoreContract,
|
||||
Pick<StalkerSelectionStoreContract, 'selectedItvId'> {}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './stalker-collection-persistence.utils';
|
||||
export * from './stalker-player-request.utils';
|
||||
export * from './stalker-request.utils';
|
||||
export * from './stalker-content-mappers';
|
||||
export * from './stalker-sorting';
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
|
||||
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
|
||||
import {
|
||||
StalkerContentType,
|
||||
StalkerRecentlyViewedItem,
|
||||
} from '../stalker-store.contracts';
|
||||
|
||||
export type StalkerCollectionPayload = StalkerPortalItem & {
|
||||
cmd?: string;
|
||||
cover?: string;
|
||||
id?: string | number;
|
||||
stream_id?: string | number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function resolveStalkerCategoryId(
|
||||
value: unknown,
|
||||
fallback: string
|
||||
): string {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function getStalkerSeriesRecentMetadata(
|
||||
selectedContentType: StalkerContentType
|
||||
): {
|
||||
category_id?: 'series';
|
||||
is_series?: true;
|
||||
} {
|
||||
if (selectedContentType !== 'series') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
category_id: 'series',
|
||||
is_series: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStalkerRecentlyViewedPayload(
|
||||
item: StalkerCollectionPayload,
|
||||
selectedContentType: StalkerContentType
|
||||
): StalkerRecentlyViewedItem {
|
||||
return {
|
||||
...item,
|
||||
category_id: resolveStalkerCategoryId(
|
||||
item.category_id,
|
||||
selectedContentType
|
||||
),
|
||||
...getStalkerSeriesRecentMetadata(selectedContentType),
|
||||
added_at: Date.now(),
|
||||
id: normalizeStalkerEntityId(item.id ?? item.stream_id ?? ''),
|
||||
title: item.title ?? item.name ?? item.o_name ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function dispatchStalkerPlaylistMetaUpdate(
|
||||
ngrxStore: Store,
|
||||
playlistId: string | undefined,
|
||||
changes: Pick<PlaylistMeta, 'favorites' | 'recentlyViewed'>
|
||||
): void {
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
ngrxStore.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: playlistId,
|
||||
...changes,
|
||||
} as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
StalkerContentItem,
|
||||
StalkerItvChannel,
|
||||
StalkerVodSource,
|
||||
} from '../../models';
|
||||
|
||||
/**
|
||||
* Convert relative URLs to absolute URLs using the portal base URL
|
||||
* Handles screenshot_uri and cmd paths that come as relative from the server
|
||||
*/
|
||||
export function makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
|
||||
if (!relativePath) return '';
|
||||
// Already absolute URL
|
||||
if (
|
||||
relativePath.startsWith('http://') ||
|
||||
relativePath.startsWith('https://')
|
||||
) {
|
||||
return relativePath;
|
||||
}
|
||||
// Parse the base URL to get origin
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
// Ensure the relative path starts with /
|
||||
const path = relativePath.startsWith('/')
|
||||
? relativePath
|
||||
: `/${relativePath}`;
|
||||
return `${url.origin}${path}`;
|
||||
} catch {
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process stalker items to convert relative URLs to absolute
|
||||
*/
|
||||
export function processItemUrls<T extends StalkerVodSource>(
|
||||
item: T,
|
||||
portalUrl: string
|
||||
): T {
|
||||
const processed = { ...item };
|
||||
|
||||
// Convert screenshot_uri to absolute URL
|
||||
if (processed.screenshot_uri) {
|
||||
processed.screenshot_uri = makeAbsoluteUrl(
|
||||
portalUrl,
|
||||
processed.screenshot_uri
|
||||
);
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
export function toStalkerContentItem(
|
||||
item: StalkerVodSource,
|
||||
portalUrl: string
|
||||
): StalkerContentItem {
|
||||
const processed = processItemUrls(item, portalUrl);
|
||||
return {
|
||||
...processed,
|
||||
cover: processed.screenshot_uri,
|
||||
};
|
||||
}
|
||||
|
||||
export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel {
|
||||
return {
|
||||
...item,
|
||||
id: item.id ?? item.stream_id ?? '',
|
||||
cmd: String(item.cmd ?? ''),
|
||||
name:
|
||||
typeof item.name === 'string' ? item.name : undefined,
|
||||
o_name:
|
||||
typeof item.o_name === 'string' ? item.o_name : undefined,
|
||||
logo:
|
||||
typeof item.logo === 'string' ? item.logo : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { PlaylistMeta, StalkerPortalActions } from 'shared-interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import {
|
||||
fetchStalkerExpireDate,
|
||||
fetchStalkerMovieFileId,
|
||||
fetchStalkerPlaybackLink,
|
||||
shouldResolveMovieFileId,
|
||||
} from './stalker-player-request.utils';
|
||||
|
||||
const PLAYLIST = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Demo Stalker',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
importDate: '2026-04-14T00:00:00.000Z',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
describe('stalker-player-request.utils', () => {
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
};
|
||||
let stalkerSession: Pick<StalkerSessionService, 'makeAuthenticatedRequest'>;
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
stalkerSession = {
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('builds create_link requests and normalizes relative portal URLs', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: '/media/video_77.mpg' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(
|
||||
{
|
||||
dataService: dataService as never,
|
||||
stalkerSession: stalkerSession as StalkerSessionService,
|
||||
},
|
||||
{
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'series',
|
||||
cmd: '/media/source.mpg',
|
||||
series: 3,
|
||||
}
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
url: PLAYLIST.portalUrl,
|
||||
macAddress: PLAYLIST.macAddress,
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
cmd: '/media/source.mpg',
|
||||
type: 'vod',
|
||||
series: '3',
|
||||
download: '0',
|
||||
disable_ad: '0',
|
||||
JsHttpRequest: '1-xml',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(streamUrl).toBe(
|
||||
'http://demo.example/stalker_portal/media/video_77.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects file-id fallback candidates and reads the movie file id', async () => {
|
||||
expect(
|
||||
shouldResolveMovieFileId(
|
||||
{ has_files: true },
|
||||
'/media/source_42.mpg'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldResolveMovieFileId({ has_files: true }, '/media/file_42.mpg')
|
||||
).toBe(false);
|
||||
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: {
|
||||
data: [{ id: 77 }],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchStalkerMovieFileId(
|
||||
{
|
||||
dataService: dataService as never,
|
||||
stalkerSession: stalkerSession as StalkerSessionService,
|
||||
},
|
||||
PLAYLIST,
|
||||
'22'
|
||||
)
|
||||
).resolves.toBe('77');
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
movie_id: '22',
|
||||
p: '1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a localized expire date string from account info', async () => {
|
||||
const expireDate = 1_713_139_200;
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: {
|
||||
account_info: {
|
||||
expire_date: expireDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchStalkerExpireDate(
|
||||
{
|
||||
dataService: dataService as never,
|
||||
stalkerSession: stalkerSession as StalkerSessionService,
|
||||
},
|
||||
PLAYLIST
|
||||
)
|
||||
).resolves.toBe(new Date(expireDate * 1000).toLocaleDateString());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { DataService } from 'services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
StalkerPortalActions,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { StalkerContentType } from '../stalker-store.contracts';
|
||||
import { executeStalkerRequest } from './stalker-request.utils';
|
||||
|
||||
export interface StalkerPlayerResponse {
|
||||
js?: {
|
||||
error?: string;
|
||||
cmd?: string;
|
||||
data?: Array<{ id?: string | number }>;
|
||||
account_info?: {
|
||||
expire_date?: string | number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface StalkerPlayerRequestDeps {
|
||||
dataService: DataService;
|
||||
stalkerSession: StalkerSessionService;
|
||||
}
|
||||
|
||||
export interface StalkerPlayableItemLike extends StalkerPortalItem {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
}
|
||||
|
||||
export function normalizeStalkerPlaybackCommand(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
candidate.startsWith('http://') ||
|
||||
candidate.startsWith('https://') ||
|
||||
candidate.startsWith('/') ||
|
||||
candidate.startsWith('?')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveStalkerPlaybackUrl(
|
||||
portalUrl: string,
|
||||
originalCmd: string,
|
||||
responseCmd: string
|
||||
): string {
|
||||
let url = normalizeStalkerPlaybackCommand(responseCmd);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const portalUrlObj = new URL(portalUrl);
|
||||
const pathParts = portalUrlObj.pathname.split('/');
|
||||
let basePath = '';
|
||||
|
||||
for (let index = 0; index < pathParts.length; index += 1) {
|
||||
if (
|
||||
pathParts[index] === 'stalker_portal' ||
|
||||
pathParts[index] === 'c' ||
|
||||
pathParts[index] === 'portal'
|
||||
) {
|
||||
basePath = '/' + pathParts.slice(1, index + 1).join('/');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (url.startsWith('?')) {
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
|
||||
if (
|
||||
normalizedCmd.startsWith('http://') ||
|
||||
normalizedCmd.startsWith('https://')
|
||||
) {
|
||||
return `${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return `${portalUrlObj.origin}${basePath}${url}`;
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function shouldResolveMovieFileId(
|
||||
item: Pick<StalkerPlayableItemLike, 'has_files'> | null | undefined,
|
||||
cmd: string
|
||||
): boolean {
|
||||
return (
|
||||
item?.has_files !== undefined &&
|
||||
!cmd.includes('://') &&
|
||||
cmd.includes('/media/') &&
|
||||
!cmd.includes('/media/file_')
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchStalkerPlaybackLink(
|
||||
deps: StalkerPlayerRequestDeps,
|
||||
options: {
|
||||
playlist: PlaylistMeta;
|
||||
selectedContentType: StalkerContentType;
|
||||
cmd: string;
|
||||
series?: number;
|
||||
forcedContentType?: StalkerContentType;
|
||||
}
|
||||
): Promise<string> {
|
||||
const contentType =
|
||||
options.forcedContentType ?? options.selectedContentType;
|
||||
const response = await executeStalkerRequest<StalkerPlayerResponse>(
|
||||
deps,
|
||||
options.playlist,
|
||||
{
|
||||
action: StalkerContentTypes[contentType].getLink,
|
||||
cmd: options.cmd,
|
||||
type: options.series ? 'vod' : contentType,
|
||||
disable_ad: '0',
|
||||
download: '0',
|
||||
JsHttpRequest: '1-xml',
|
||||
...(options.series ? { series: String(options.series) } : {}),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.js?.error) {
|
||||
throw new Error(response.js.error);
|
||||
}
|
||||
|
||||
const streamUrl = resolveStalkerPlaybackUrl(
|
||||
options.playlist.portalUrl ?? '',
|
||||
options.cmd,
|
||||
response.js?.cmd ?? ''
|
||||
);
|
||||
|
||||
if (!streamUrl) {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
|
||||
return streamUrl;
|
||||
}
|
||||
|
||||
export async function fetchStalkerMovieFileId(
|
||||
deps: StalkerPlayerRequestDeps,
|
||||
playlist: PlaylistMeta,
|
||||
movieId: string
|
||||
): Promise<string | null> {
|
||||
const response = await executeStalkerRequest<StalkerPlayerResponse>(
|
||||
deps,
|
||||
playlist,
|
||||
{
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
movie_id: movieId,
|
||||
p: '1',
|
||||
}
|
||||
);
|
||||
|
||||
const fileId = response?.js?.data?.[0]?.id;
|
||||
return fileId == null ? null : String(fileId);
|
||||
}
|
||||
|
||||
export async function fetchStalkerExpireDate(
|
||||
deps: StalkerPlayerRequestDeps,
|
||||
playlist: PlaylistMeta
|
||||
): Promise<string> {
|
||||
const response = await executeStalkerRequest<StalkerPlayerResponse>(
|
||||
deps,
|
||||
playlist,
|
||||
{
|
||||
type: 'account_info',
|
||||
action: 'get_main_info',
|
||||
JsHttpRequest: '1-xml',
|
||||
}
|
||||
);
|
||||
|
||||
const expireDate = response?.js?.account_info?.expire_date;
|
||||
const numericExpireDate = Number(expireDate);
|
||||
|
||||
if (expireDate && !Number.isNaN(numericExpireDate)) {
|
||||
return new Date(numericExpireDate * 1000).toLocaleDateString();
|
||||
}
|
||||
|
||||
return expireDate ? String(expireDate) : 'Unknown';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DataService } from 'services';
|
||||
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
|
||||
export interface StalkerRequestDeps {
|
||||
dataService: DataService;
|
||||
stalkerSession: StalkerSessionService;
|
||||
}
|
||||
|
||||
export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist {
|
||||
return {
|
||||
lastUsage: '',
|
||||
...playlist,
|
||||
} as Playlist;
|
||||
}
|
||||
|
||||
export async function executeStalkerRequest<T>(
|
||||
deps: StalkerRequestDeps,
|
||||
playlist: PlaylistMeta,
|
||||
params: Record<string, string | number>
|
||||
): Promise<T> {
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
return deps.stalkerSession.makeAuthenticatedRequest<T>(
|
||||
toStalkerSessionPlaylist(playlist),
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
return deps.dataService.sendIpcEvent<T>(STALKER_REQUEST, {
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
StalkerSeason,
|
||||
StalkerVodSeriesEpisode,
|
||||
StalkerVodSeriesSeason,
|
||||
} from '../../models';
|
||||
|
||||
export function extractNumericValue(str: string): number {
|
||||
const matches = str.match(/\d+/);
|
||||
if (matches) {
|
||||
return parseInt(matches[0], 10);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
|
||||
if (!array) return [];
|
||||
const key = 'name';
|
||||
return [...array].sort((a, b) => {
|
||||
const numericA = extractNumericValue(a[key]);
|
||||
const numericB = extractNumericValue(b[key]);
|
||||
return numericA - numericB;
|
||||
});
|
||||
}
|
||||
|
||||
export function sortVodSeriesSeasonsByNumber(
|
||||
array: StalkerVodSeriesSeason[]
|
||||
): StalkerVodSeriesSeason[] {
|
||||
if (!array) return [];
|
||||
return [...array].sort((a, b) => {
|
||||
const numA = Number(a.season_number ?? 0) || 0;
|
||||
const numB = Number(b.season_number ?? 0) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2")
|
||||
*/
|
||||
export function sortEpisodesByNumber(
|
||||
episodes: StalkerVodSeriesEpisode[]
|
||||
): StalkerVodSeriesEpisode[] {
|
||||
if (!episodes) return [];
|
||||
return [...episodes].sort((a, b) => {
|
||||
const numA = Number(a.series_number ?? 0) || 0;
|
||||
const numB = Number(b.series_number ?? 0) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'portal-stalker-feature',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/portal/stalker/feature',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "portal-stalker-feature",
|
||||
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/portal/stalker/feature/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:stalker", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/portal/stalker/feature/jest.config.ts",
|
||||
"tsConfig": "libs/portal/stalker/feature/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
|
||||
export * from './lib/stalker-collection-detail.component';
|
||||
export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component';
|
||||
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
|
||||
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
|
||||
export * from './lib/stalker-catalog-facade.service';
|
||||
export * from './lib/stalker-feature.routes';
|
||||
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
|
||||
export * from './lib/stalker-search/stalker-search.component';
|
||||
export * from './lib/stalker-series-view/stalker-series-view.component';
|
||||
export * from './lib/stalker-workspace-route-session.service';
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
@if (contentType() === 'vod') {
|
||||
@if ((selectedItem()?.series?.length ?? 0) > 0) {
|
||||
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
|
||||
} @else if (isSeriesDetail()) {
|
||||
<app-stalker-series-view />
|
||||
} @else if (vodDetailsItem()) {
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem()!"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
[playbackPosition]="selectedVodPlaybackPosition()"
|
||||
[inlinePlayback]="inlinePlayback()"
|
||||
[externalPlayback]="externalPlayback.activeSession()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(resumeClicked)="onVodResume($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(downloadRequested)="onVodDownload($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
|
||||
(inlinePlaybackClosed)="closeInlinePlayer()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
/>
|
||||
}
|
||||
} @else if (contentType() === 'series') {
|
||||
<app-stalker-series-view />
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
getStalkerReturnToState,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
createLogger,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
isSelectedStalkerVodFavorite,
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
StalkerSelectedVodItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { VodDetailsComponent } from '@iptvnator/ui/playback';
|
||||
import { DownloadsService, PlaylistsService } from 'services';
|
||||
import {
|
||||
createStalkerVodItem,
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
StalkerVodDetails,
|
||||
VodDetailsItem,
|
||||
} from 'shared-interfaces';
|
||||
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
|
||||
interface DownloadVodData {
|
||||
id?: string | number;
|
||||
has_files?: unknown;
|
||||
info?: {
|
||||
name?: string;
|
||||
movie_image?: string;
|
||||
};
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-catalog-detail',
|
||||
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
|
||||
templateUrl: './stalker-catalog-detail.component.html',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
private readonly catalog = inject(StalkerCatalogFacadeService);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly router = inject(Router);
|
||||
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly logger = createLogger('StalkerCatalogDetail');
|
||||
private readonly favoritesRefresh = createRefreshTrigger();
|
||||
|
||||
readonly contentType = this.catalog.contentType;
|
||||
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
|
||||
() =>
|
||||
(this.catalog.selectedItem() as StalkerSelectedVodItem | null) ??
|
||||
null
|
||||
);
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
||||
null
|
||||
);
|
||||
private lastInlineSaveTime = 0;
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
|
||||
readonly isSeriesDetail = computed(() => {
|
||||
const item = this.selectedItem();
|
||||
return Boolean(
|
||||
item &&
|
||||
(this.contentType() === 'series' ||
|
||||
item.is_series === true ||
|
||||
String(item.is_series) === '1')
|
||||
);
|
||||
});
|
||||
|
||||
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
|
||||
const item = this.selectedItem();
|
||||
if (!item || this.contentType() !== 'vod' || this.isSeriesDetail()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createStalkerVodItem(
|
||||
item as unknown as StalkerVodDetails,
|
||||
this.catalog.playlist()?.id ?? ''
|
||||
);
|
||||
});
|
||||
|
||||
readonly selectedVodPlaybackPosition = computed<number | null>(
|
||||
() => this.selectedVodPosition()?.positionSeconds ?? null
|
||||
);
|
||||
|
||||
readonly portalFavorites = createPortalFavoritesResource(
|
||||
this.playlistService,
|
||||
() => this.catalog.playlist()?.id,
|
||||
() => this.favoritesRefresh.refreshVersion()
|
||||
);
|
||||
|
||||
readonly isSelectedVodFavorite = computed<boolean>(() =>
|
||||
isSelectedStalkerVodFavorite(
|
||||
this.vodDetailsItem(),
|
||||
this.portalFavorites.value() ?? []
|
||||
)
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const item = this.selectedItem();
|
||||
const playlistId = this.catalog.playlist()?.id;
|
||||
|
||||
if (
|
||||
!item ||
|
||||
!playlistId ||
|
||||
this.contentType() !== 'vod' ||
|
||||
this.isSeriesDetail()
|
||||
) {
|
||||
this.selectedVodPosition.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadSelectedVodPosition(playlistId, Number(item.id));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const selectedItemId = this.selectedItem()?.id;
|
||||
void selectedItemId;
|
||||
this.closeInlinePlayer();
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
this.unsubscribePositionUpdates =
|
||||
window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const currentItem = this.selectedItem();
|
||||
if (
|
||||
data.contentType !== 'vod' ||
|
||||
data.playlistId !== this.catalog.playlist()?.id ||
|
||||
data.contentXtreamId !== Number(currentItem?.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedVodPosition.set(data);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
void this.startStalkerVodPlayback(
|
||||
item.cmd,
|
||||
item.data.info?.name,
|
||||
item.data.info?.movie_image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onVodResume(event: {
|
||||
item: VodDetailsItem;
|
||||
positionSeconds: number;
|
||||
}): void {
|
||||
if (event.item.type === 'stalker') {
|
||||
void this.startStalkerVodPlayback(
|
||||
event.item.cmd,
|
||||
event.item.data.info?.name,
|
||||
event.item.data.info?.movie_image,
|
||||
event.positionSeconds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onVodFavoriteToggled(event: {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}): void {
|
||||
toggleStalkerVodFavorite(event, {
|
||||
addToFavorites: (item, onDone) =>
|
||||
this.catalog.addToFavorites(item, onDone),
|
||||
removeFromFavorites: (favoriteId, onDone) =>
|
||||
this.catalog.removeFromFavorites(favoriteId, onDone),
|
||||
onComplete: () => {
|
||||
this.favoritesRefresh.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onVodBack(): void {
|
||||
const returnTo = getStalkerReturnToState(window.history.state);
|
||||
this.closeInlinePlayer();
|
||||
this.catalog.clearSelectedItem();
|
||||
|
||||
if (returnTo) {
|
||||
void this.router.navigateByUrl(returnTo);
|
||||
}
|
||||
}
|
||||
|
||||
handleInlineTimeUpdate(event: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}): void {
|
||||
const playback = this.inlinePlayback();
|
||||
if (!playback?.contentInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastInlineSaveTime <= 15000) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastInlineSaveTime = now;
|
||||
const position: PlaybackPositionData = {
|
||||
...playback.contentInfo,
|
||||
positionSeconds: Math.floor(event.currentTime),
|
||||
durationSeconds: Math.floor(event.duration),
|
||||
};
|
||||
|
||||
void this.playbackPositions.savePlaybackPosition(
|
||||
playback.contentInfo.playlistId,
|
||||
position
|
||||
);
|
||||
this.selectedVodPosition.set(position);
|
||||
}
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.inlinePlayback.set(null);
|
||||
this.lastInlineSaveTime = 0;
|
||||
}
|
||||
|
||||
showCopyNotification(): void {
|
||||
this.snackBar.open(
|
||||
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
|
||||
null,
|
||||
{
|
||||
duration: 2000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async onVodDownload(item: VodDetailsItem): Promise<void> {
|
||||
if (item.type !== 'stalker') {
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = this.catalog.playlist();
|
||||
if (!playlist || !playlist.portalUrl || !playlist.macAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cmdToUse = item.cmd;
|
||||
const itemData = item.data as DownloadVodData;
|
||||
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
|
||||
|
||||
if (
|
||||
itemData?.has_files !== undefined &&
|
||||
cmdToUse &&
|
||||
!cmdToUse.includes('://') &&
|
||||
cmdToUse.includes('/media/') &&
|
||||
!cmdToUse.includes('/media/file_')
|
||||
) {
|
||||
const fileId =
|
||||
await this.catalog.fetchMovieFileId(normalizedItemId);
|
||||
if (fileId) {
|
||||
cmdToUse = `/media/file_${fileId}.mpg`;
|
||||
}
|
||||
}
|
||||
|
||||
const url = await this.catalog.fetchLinkToPlay(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
cmdToUse
|
||||
);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
|
||||
|
||||
await this.downloadsService.startDownload({
|
||||
playlistId: playlist.id,
|
||||
xtreamId: numericId,
|
||||
contentType: 'vod',
|
||||
title: itemData?.info?.name || itemData?.title || 'Unknown',
|
||||
url,
|
||||
posterUrl: itemData?.info?.movie_image,
|
||||
headers: {
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referer,
|
||||
origin: playlist.origin,
|
||||
},
|
||||
playlistName: playlist.title || 'Stalker Portal',
|
||||
playlistType: 'stalker',
|
||||
portalUrl: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.closeInlinePlayer();
|
||||
this.unsubscribePositionUpdates?.();
|
||||
}
|
||||
|
||||
private async loadSelectedVodPosition(
|
||||
playlistId: string,
|
||||
vodId: number
|
||||
): Promise<void> {
|
||||
if (Number.isNaN(vodId)) {
|
||||
this.selectedVodPosition.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const position = await this.playbackPositions.getPlaybackPosition(
|
||||
playlistId,
|
||||
vodId,
|
||||
'vod'
|
||||
);
|
||||
this.selectedVodPosition.set(position ?? null);
|
||||
}
|
||||
|
||||
private async startStalkerVodPlayback(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
startTime?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const playback = await this.catalog.resolveVodPlayback(
|
||||
cmd,
|
||||
title,
|
||||
thumbnail,
|
||||
startTime
|
||||
);
|
||||
|
||||
this.lastInlineSaveTime = 0;
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
void this.portalPlayer.openResolvedPlayback(playback, true);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start inline VOD playback', error);
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message === 'nothing_to_play'
|
||||
? this.translateService.instant(
|
||||
'PORTALS.CONTENT_NOT_AVAILABLE'
|
||||
)
|
||||
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
|
||||
this.snackBar.open(errorMessage, null, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from 'shared-interfaces';
|
||||
import { StalkerCatalogFacadeService } from './stalker-catalog-facade.service';
|
||||
|
||||
describe('StalkerCatalogFacadeService', () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Demo Stalker',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
userAgent: 'DemoAgent',
|
||||
referrer: 'http://demo.example',
|
||||
origin: 'http://demo.example',
|
||||
};
|
||||
const unsubscribe = jest.fn();
|
||||
let playbackUpdateHandler:
|
||||
| ((data: PlaybackPositionData) => void)
|
||||
| undefined;
|
||||
let playbackPositions: {
|
||||
savePlaybackPosition: jest.Mock<Promise<void>, [string, PlaybackPositionData]>;
|
||||
getPlaybackPosition: jest.Mock<Promise<PlaybackPositionData | null>, [string, number, 'vod' | 'episode']>;
|
||||
getSeriesPlaybackPositions: jest.Mock<Promise<PlaybackPositionData[]>, [string, number]>;
|
||||
getRecentPlaybackPositions?: jest.Mock;
|
||||
getAllPlaybackPositions: jest.Mock<Promise<PlaybackPositionData[]>, [string]>;
|
||||
clearPlaybackPosition: jest.Mock<Promise<void>, [string, number, 'vod' | 'episode']>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
playbackUpdateHandler = undefined;
|
||||
unsubscribe.mockReset();
|
||||
playbackPositions = {
|
||||
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
||||
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
(window as Window & { electron?: typeof window.electron }).electron = {
|
||||
...(window.electron ?? {}),
|
||||
onPlaybackPositionUpdate: jest.fn(
|
||||
(handler: (data: PlaybackPositionData) => void) => {
|
||||
playbackUpdateHandler = handler;
|
||||
return unsubscribe;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerCatalogFacadeService,
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
selectedContentType: signal<'vod' | 'series' | 'itv'>('vod'),
|
||||
limit: signal(14),
|
||||
page: signal(0),
|
||||
getSelectedCategory: signal(null),
|
||||
getPaginatedContent: signal([]),
|
||||
selectedItem: signal(null),
|
||||
getTotalPages: signal(0),
|
||||
isPaginatedContentLoading: signal(false),
|
||||
currentPlaylist: signal(playlist),
|
||||
getSelectedCategoryName: jest.fn(() => null),
|
||||
setSelectedCategory: jest.fn(),
|
||||
clearSelectedItem: jest.fn(),
|
||||
setPage: jest.fn(),
|
||||
setLimit: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
createLinkToPlayVod: jest.fn(),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
fetchMovieFileId: jest.fn(),
|
||||
fetchLinkToPlay: jest.fn(),
|
||||
resolveVodPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: playbackPositions,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('persists matching external playback updates for the current playlist', async () => {
|
||||
TestBed.inject(StalkerCatalogFacadeService);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(playbackUpdateHandler).toBeDefined();
|
||||
|
||||
playbackUpdateHandler?.({
|
||||
playlistId: playlist._id,
|
||||
contentXtreamId: 17359,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 42,
|
||||
durationSeconds: 5400,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(playbackPositions.savePlaybackPosition).toHaveBeenCalledWith(
|
||||
playlist._id,
|
||||
expect.objectContaining({
|
||||
playlistId: playlist._id,
|
||||
contentXtreamId: 17359,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 42,
|
||||
durationSeconds: 5400,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores external playback updates for other playlists', async () => {
|
||||
TestBed.inject(StalkerCatalogFacadeService);
|
||||
await Promise.resolve();
|
||||
|
||||
playbackUpdateHandler?.({
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 17359,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 42,
|
||||
durationSeconds: 5400,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(playbackPositions.savePlaybackPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
Injectable,
|
||||
Provider,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
buildStalkerSelectedVodItem,
|
||||
StalkerStore,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
PortalCatalogItemProgress,
|
||||
PortalCatalogPlaylistMeta,
|
||||
PortalCatalogSortMode,
|
||||
PORTAL_CATALOG_FACADE,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
StalkerPortalCatalogFacade,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from 'shared-interfaces';
|
||||
|
||||
function calculateProgress(position: PlaybackPositionData | undefined): number {
|
||||
if (!position || !position.durationSeconds) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const percent =
|
||||
(position.positionSeconds / position.durationSeconds) * 100;
|
||||
|
||||
if (position.positionSeconds > 10 && percent < 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.round(percent));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StalkerCatalogFacadeService
|
||||
implements
|
||||
StalkerPortalCatalogFacade<
|
||||
Record<string, unknown>,
|
||||
StalkerVodSource,
|
||||
StalkerVodSource
|
||||
>
|
||||
{
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly stalkerPositions = signal<Map<string, PlaybackPositionData>>(
|
||||
new Map()
|
||||
);
|
||||
private readonly stalkerSeriesPositions = signal<
|
||||
Map<number, PlaybackPositionData[]>
|
||||
>(new Map());
|
||||
private loadedPositionsForPlaylistId: string | null = null;
|
||||
|
||||
readonly provider = 'stalker' as const;
|
||||
readonly pageSizeOptions = [14] as const;
|
||||
readonly contentType = this.stalkerStore.selectedContentType;
|
||||
readonly limit = this.stalkerStore.limit;
|
||||
readonly pageIndex = this.stalkerStore.page;
|
||||
readonly selectedCategory = this.stalkerStore.getSelectedCategory;
|
||||
readonly paginatedContent = computed(
|
||||
() => this.stalkerStore.getPaginatedContent() ?? []
|
||||
);
|
||||
readonly selectedItem = this.stalkerStore.selectedItem;
|
||||
readonly totalPages = this.stalkerStore.getTotalPages;
|
||||
readonly isPaginatedContentLoading =
|
||||
this.stalkerStore.isPaginatedContentLoading;
|
||||
readonly selectedCategoryTitle = computed(() => {
|
||||
const category = this.selectedCategory();
|
||||
const fromCategory = String(
|
||||
category?.['category_name'] ?? category?.['name'] ?? ''
|
||||
);
|
||||
|
||||
if (fromCategory) {
|
||||
return fromCategory;
|
||||
}
|
||||
|
||||
return this.stalkerStore.getSelectedCategoryName() ?? '';
|
||||
});
|
||||
readonly categoryItemCount = computed(() => this.stalkerStore.totalCount());
|
||||
readonly contentSortMode = computed<PortalCatalogSortMode | null>(
|
||||
() => null
|
||||
);
|
||||
readonly playlist = computed<PortalCatalogPlaylistMeta | null>(() => {
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
if (!playlist?._id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: playlist._id,
|
||||
title: playlist.title ?? 'Stalker Portal',
|
||||
portalUrl: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const playlistId = this.playlist()?.id;
|
||||
|
||||
if (!playlistId) {
|
||||
this.loadedPositionsForPlaylistId = null;
|
||||
this.stalkerPositions.set(new Map());
|
||||
this.stalkerSeriesPositions.set(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlistId === this.loadedPositionsForPlaylistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedPositionsForPlaylistId = playlistId;
|
||||
void this.loadStalkerPositions(playlistId);
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
const unsubscribe = window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
if (data.playlistId !== this.playlist()?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.playbackPositions.savePlaybackPosition(
|
||||
data.playlistId,
|
||||
data
|
||||
);
|
||||
|
||||
if (data.contentType === 'vod') {
|
||||
this.updateVodPlaybackPosition(data);
|
||||
}
|
||||
|
||||
if (
|
||||
data.contentType === 'episode' &&
|
||||
data.seriesXtreamId
|
||||
) {
|
||||
this.updateSeriesPlaybackPosition(data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (typeof unsubscribe === 'function') {
|
||||
this.destroyRef.onDestroy(unsubscribe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialize(categoryId?: string | null): void {
|
||||
this.clearSelectedItem();
|
||||
if (categoryId) {
|
||||
this.stalkerStore.setSelectedCategory(categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.stalkerStore.setSelectedCategory('*');
|
||||
}
|
||||
|
||||
clearSelectedItem(): void {
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
this.stalkerStore.setPage(page);
|
||||
}
|
||||
|
||||
setLimit(limit: number): void {
|
||||
this.stalkerStore.setLimit(limit);
|
||||
}
|
||||
|
||||
setContentSortMode(mode: PortalCatalogSortMode): void {
|
||||
void mode;
|
||||
// Stalker catalog content is server-paginated and does not support local sort modes.
|
||||
}
|
||||
|
||||
selectItem(item: StalkerVodSource): string[] | null {
|
||||
const needsSeriesFetch =
|
||||
this.contentType() === 'vod' &&
|
||||
(item.is_series === '1' || item.is_series === 1);
|
||||
|
||||
this.stalkerStore.setSelectedItem(
|
||||
buildStalkerSelectedVodItem(item, needsSeriesFetch)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
getItemProgress(item: StalkerVodSource): PortalCatalogItemProgress {
|
||||
const numericId = Number(item.id);
|
||||
if (Number.isNaN(numericId)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const hasSeriesProgress = Boolean(
|
||||
this.stalkerSeriesPositions().get(numericId)?.length
|
||||
);
|
||||
const isSeries =
|
||||
this.contentType() === 'series' ||
|
||||
item.is_series === '1' ||
|
||||
item.is_series === 1;
|
||||
|
||||
if (hasSeriesProgress) {
|
||||
return { hasSeriesProgress: true };
|
||||
}
|
||||
|
||||
if (isSeries) {
|
||||
return { hasSeriesProgress: false };
|
||||
}
|
||||
|
||||
const progress = calculateProgress(
|
||||
this.stalkerPositions().get(`vod_${numericId}`)
|
||||
);
|
||||
return {
|
||||
progress,
|
||||
isWatched: progress >= 90,
|
||||
};
|
||||
}
|
||||
|
||||
async createLinkToPlayVod(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string
|
||||
): Promise<void> {
|
||||
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void {
|
||||
this.stalkerStore.addToFavorites(item, onDone);
|
||||
}
|
||||
|
||||
removeFromFavorites(favoriteId: string, onDone?: () => void): void {
|
||||
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
|
||||
}
|
||||
|
||||
fetchMovieFileId(itemId: string): Promise<string | null> {
|
||||
return this.stalkerStore.fetchMovieFileId(itemId);
|
||||
}
|
||||
|
||||
async fetchLinkToPlay(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
): Promise<string> {
|
||||
return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd);
|
||||
}
|
||||
|
||||
resolveVodPlayback(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
startTime?: number
|
||||
) {
|
||||
return this.stalkerStore.resolveVodPlayback(
|
||||
cmd,
|
||||
title,
|
||||
thumbnail,
|
||||
undefined,
|
||||
undefined,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
private async loadStalkerPositions(playlistId: string): Promise<void> {
|
||||
const positions =
|
||||
await this.playbackPositions.getAllPlaybackPositions(playlistId);
|
||||
|
||||
const positionsMap = new Map<string, PlaybackPositionData>();
|
||||
const seriesMap = new Map<number, PlaybackPositionData[]>();
|
||||
|
||||
positions.forEach((position) => {
|
||||
positionsMap.set(
|
||||
`${position.contentType}_${position.contentXtreamId}`,
|
||||
position
|
||||
);
|
||||
|
||||
if (position.contentType === 'episode' && position.seriesXtreamId) {
|
||||
const existing = seriesMap.get(position.seriesXtreamId) ?? [];
|
||||
existing.push(position);
|
||||
seriesMap.set(position.seriesXtreamId, existing);
|
||||
}
|
||||
});
|
||||
|
||||
this.stalkerPositions.set(positionsMap);
|
||||
this.stalkerSeriesPositions.set(seriesMap);
|
||||
}
|
||||
|
||||
private updateVodPlaybackPosition(position: PlaybackPositionData): void {
|
||||
const updated = new Map(this.stalkerPositions());
|
||||
updated.set(`vod_${position.contentXtreamId}`, position);
|
||||
this.stalkerPositions.set(updated);
|
||||
}
|
||||
|
||||
private updateSeriesPlaybackPosition(position: PlaybackPositionData): void {
|
||||
if (!position.seriesXtreamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = new Map(this.stalkerSeriesPositions());
|
||||
const positionsForSeries = [
|
||||
...(updated.get(position.seriesXtreamId) ?? []).filter(
|
||||
(item) => item.contentXtreamId !== position.contentXtreamId
|
||||
),
|
||||
position,
|
||||
];
|
||||
updated.set(position.seriesXtreamId, positionsForSeries);
|
||||
this.stalkerSeriesPositions.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
export function provideStalkerCatalogFacade(): Provider[] {
|
||||
return [
|
||||
StalkerCatalogFacadeService,
|
||||
{
|
||||
provide: PORTAL_CATALOG_FACADE,
|
||||
useExisting: StalkerCatalogFacadeService,
|
||||
},
|
||||
];
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<div class="search-panel">
|
||||
<label class="search-field">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
|
||||
[(ngModel)]="searchString"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="channels-list">
|
||||
@if (filteredItems().length > 0) {
|
||||
@for (item of filteredItems(); track item.id) {
|
||||
<app-channel-list-item
|
||||
[name]="item.o_name || item.name || ''"
|
||||
[logo]="item.logo"
|
||||
[selected]="isSelected(item)"
|
||||
[epgProgram]="
|
||||
epgPrograms.get(normalizeStalkerEntityId(item.id)) ?? null
|
||||
"
|
||||
[progressPercentage]="
|
||||
currentProgramsProgress.get(normalizeStalkerEntityId(item.id)) ?? 0
|
||||
"
|
||||
[showFavoriteButton]="true"
|
||||
[showProgramInfoButton]="false"
|
||||
[isFavorite]="isFavorite(item)"
|
||||
(clicked)="onPlay(item)"
|
||||
(favoriteToggled)="onFavoriteToggle(item)"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
<div class="empty-search-state">
|
||||
<div class="empty-icon-wrapper">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
</div>
|
||||
<h3>{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}</h3>
|
||||
<p>We couldn't find anything matching "{{ searchString() }}"</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.channels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 56px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
padding: 4px 8px 12px;
|
||||
box-sizing: border-box;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
padding: 10px 12px;
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(127, 127, 127, 0.22);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
|
||||
rgba(127, 127, 127, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--app-selection-border);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-selection-color) 16%, transparent);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
opacity: 0.68;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-search-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon-wrapper {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
|
||||
rgba(127, 127, 127, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
box-shadow:
|
||||
0 8px 24px -8px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||
|
||||
mat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 32px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
opacity: 0.65;
|
||||
line-height: 1.4;
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 350px) {
|
||||
.channels-list {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from 'components';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerCollectionChannelsListComponent } from './stalker-collection-channels-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-item',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubChannelListItemComponent {
|
||||
readonly name = input('');
|
||||
readonly logo = input<string | null | undefined>(null);
|
||||
readonly selected = input(false);
|
||||
readonly epgProgram = input<unknown>(null);
|
||||
readonly progressPercentage = input(0);
|
||||
readonly showFavoriteButton = input(false);
|
||||
readonly showProgramInfoButton = input(false);
|
||||
readonly isFavorite = input(false);
|
||||
readonly clicked = output<void>();
|
||||
readonly favoriteToggled = output<void>();
|
||||
}
|
||||
|
||||
describe('StalkerCollectionChannelsListComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerCollectionChannelsListComponent>;
|
||||
let component: StalkerCollectionChannelsListComponent;
|
||||
|
||||
const bulkItvEpgByChannel = signal<Record<string, EpgProgram[]>>({});
|
||||
const stalkerStore = {
|
||||
bulkItvEpgByChannel,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
bulkItvEpgByChannel.set({});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerCollectionChannelsListComponent],
|
||||
providers: [{ provide: StalkerStore, useValue: stalkerStore }],
|
||||
})
|
||||
.overrideComponent(StalkerCollectionChannelsListComponent, {
|
||||
remove: {
|
||||
imports: [ChannelListItemComponent, TranslatePipe],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubChannelListItemComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StalkerCollectionChannelsListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('items', [
|
||||
{
|
||||
id: '10001',
|
||||
name: 'Alpha TV',
|
||||
o_name: 'Alpha TV',
|
||||
logo: 'alpha.png',
|
||||
},
|
||||
{
|
||||
id: '10002',
|
||||
name: 'Beta TV',
|
||||
o_name: 'Beta TV',
|
||||
logo: 'beta.png',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('keeps row previews empty until bulk epg has been loaded', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.epgPrograms.size).toBe(0);
|
||||
expect(component.currentProgramsProgress.size).toBe(0);
|
||||
});
|
||||
|
||||
it('derives row previews from cached bulk epg', () => {
|
||||
bulkItvEpgByChannel.set({
|
||||
'10001': [buildProgram('10001', 'Current Show')],
|
||||
'10002': [buildProgram('10002', 'Other Show')],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.epgPrograms.get('10001')?.title).toBe('Current Show');
|
||||
expect(component.epgPrograms.get('10002')?.title).toBe('Other Show');
|
||||
});
|
||||
});
|
||||
|
||||
function buildProgram(channelId: string, title: string): EpgProgram {
|
||||
const startTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
|
||||
const stopTimestamp = startTimestamp + 30 * 60;
|
||||
|
||||
return {
|
||||
start: new Date(startTimestamp * 1000).toISOString(),
|
||||
stop: new Date(stopTimestamp * 1000).toISOString(),
|
||||
channel: channelId,
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
};
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from 'components';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
|
||||
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-collection-channels-list',
|
||||
imports: [ChannelListItemComponent, FormsModule, MatIconModule, TranslatePipe],
|
||||
templateUrl: './stalker-collection-channels-list.component.html',
|
||||
styleUrl: './stalker-collection-channels-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerCollectionChannelsListComponent {
|
||||
readonly items = input<StalkerVodSource[]>([]);
|
||||
readonly selectedItemId = input<string | number | null>(null);
|
||||
readonly favoriteIds = input<Map<string | number, boolean>>(new Map());
|
||||
|
||||
readonly playClicked = output<StalkerVodSource>();
|
||||
readonly favoriteToggled = output<StalkerVodSource>();
|
||||
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
|
||||
readonly searchString = signal('');
|
||||
readonly filteredItems = computed(() => {
|
||||
const search = this.searchString().trim().toLowerCase();
|
||||
if (!search) {
|
||||
return this.items();
|
||||
}
|
||||
|
||||
return this.items().filter((item) =>
|
||||
`${item.o_name ?? ''} ${item.name ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
readonly epgPrograms = new Map<string | number, EpgProgram>();
|
||||
readonly currentProgramsProgress = new Map<string | number, number>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const items = this.items();
|
||||
const bulkProgramsByChannel = this.stalkerStore.bulkItvEpgByChannel();
|
||||
this.syncBulkEpgPreviews(items, bulkProgramsByChannel);
|
||||
});
|
||||
}
|
||||
|
||||
onPlay(item: StalkerVodSource): void {
|
||||
this.playClicked.emit(item);
|
||||
}
|
||||
|
||||
onFavoriteToggle(item: StalkerVodSource): void {
|
||||
this.favoriteToggled.emit(item);
|
||||
}
|
||||
|
||||
isSelected(item: StalkerVodSource): boolean {
|
||||
return String(this.selectedItemId() ?? '') === normalizeStalkerEntityId(item.id);
|
||||
}
|
||||
|
||||
isFavorite(item: StalkerVodSource): boolean {
|
||||
return this.favoriteIds().get(normalizeStalkerEntityId(item.id)) ?? false;
|
||||
}
|
||||
|
||||
private syncBulkEpgPreviews(
|
||||
items: StalkerVodSource[],
|
||||
bulkProgramsByChannel: Record<string, EpgProgram[]>
|
||||
): void {
|
||||
this.epgPrograms.clear();
|
||||
this.currentProgramsProgress.clear();
|
||||
|
||||
if (items.length === 0 || Object.keys(bulkProgramsByChannel).length === 0) {
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const channelId = normalizeStalkerEntityId(item.id);
|
||||
const currentProgram = this.findCurrentProgram(
|
||||
bulkProgramsByChannel[channelId] ?? []
|
||||
);
|
||||
|
||||
if (!currentProgram) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.epgPrograms.set(channelId, currentProgram);
|
||||
this.updateProgramProgress(channelId, currentProgram);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private updateProgramProgress(
|
||||
channelId: string | number,
|
||||
program: EpgProgram
|
||||
): void {
|
||||
const startMs = this.getProgramTimestampMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stopMs = this.getProgramTimestampMs(
|
||||
program.stop,
|
||||
program.stopTimestamp
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (
|
||||
Number.isFinite(startMs) &&
|
||||
Number.isFinite(stopMs) &&
|
||||
nowMs >= startMs &&
|
||||
nowMs <= stopMs &&
|
||||
stopMs > startMs
|
||||
) {
|
||||
this.currentProgramsProgress.set(
|
||||
channelId,
|
||||
((nowMs - startMs) / (stopMs - startMs)) * 100
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentProgramsProgress.delete(channelId);
|
||||
}
|
||||
|
||||
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
|
||||
const nowMs = Date.now();
|
||||
|
||||
return (
|
||||
programs.find((program) => {
|
||||
const startMs = this.getProgramTimestampMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stopMs = this.getProgramTimestampMs(
|
||||
program.stop,
|
||||
program.stopTimestamp
|
||||
);
|
||||
|
||||
return nowMs >= startMs && nowMs <= stopMs;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private getProgramTimestampMs(
|
||||
rawDate: string,
|
||||
timestamp?: number | null
|
||||
): number {
|
||||
const parsedTimestamp = Number(timestamp);
|
||||
if (Number.isFinite(parsedTimestamp) && parsedTimestamp > 0) {
|
||||
return parsedTimestamp * 1000;
|
||||
}
|
||||
|
||||
const parsedDate = Date.parse(rawDate);
|
||||
return Number.isFinite(parsedDate) ? parsedDate : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { of } from 'rxjs';
|
||||
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
|
||||
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-hero',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubContentHeroComponent {
|
||||
readonly isLoading = input(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-inline-detail',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubStalkerInlineDetailComponent {
|
||||
readonly categoryId = input<'vod' | 'series' | null>(null);
|
||||
readonly seriesItem = input<unknown>(null);
|
||||
readonly isSeries = input(false);
|
||||
readonly vodDetailsItem = input<unknown>(null);
|
||||
readonly isFavorite = input(false);
|
||||
readonly backClicked = output<void>();
|
||||
readonly playClicked = output<unknown>();
|
||||
readonly favoriteToggled = output<unknown>();
|
||||
}
|
||||
|
||||
describe('StalkerCollectionDetailComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerCollectionDetailComponent>;
|
||||
let currentPlaylist: ReturnType<typeof signal<Playlist | undefined>>;
|
||||
let selectedContentType: ReturnType<
|
||||
typeof signal<'vod' | 'itv' | 'series'>
|
||||
>;
|
||||
let selectedCategoryId: ReturnType<typeof signal<string | null>>;
|
||||
let selectedItem: ReturnType<typeof signal<unknown>>;
|
||||
let stalkerStore: {
|
||||
currentPlaylist: typeof currentPlaylist;
|
||||
selectedContentType: typeof selectedContentType;
|
||||
selectedCategoryId: typeof selectedCategoryId;
|
||||
selectedItem: typeof selectedItem;
|
||||
setCurrentPlaylist: jest.Mock;
|
||||
setSelectedContentType: jest.Mock;
|
||||
setSelectedCategory: jest.Mock;
|
||||
setSelectedItem: jest.Mock;
|
||||
addToFavorites: jest.Mock;
|
||||
removeFromFavorites: jest.Mock;
|
||||
createLinkToPlayVod: jest.Mock;
|
||||
};
|
||||
|
||||
const playlist = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker Portal',
|
||||
portalUrl: 'http://portal.example/portal.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
favorites: [],
|
||||
} as unknown as Playlist;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentPlaylist = signal<Playlist | undefined>(undefined);
|
||||
selectedContentType = signal<'vod' | 'itv' | 'series'>('vod');
|
||||
selectedCategoryId = signal<string | null>(null);
|
||||
selectedItem = signal<unknown>(null);
|
||||
stalkerStore = {
|
||||
currentPlaylist,
|
||||
selectedContentType,
|
||||
selectedCategoryId,
|
||||
selectedItem,
|
||||
setCurrentPlaylist: jest.fn(async (value: Playlist | undefined) => {
|
||||
currentPlaylist.set(value);
|
||||
}),
|
||||
setSelectedContentType: jest.fn(
|
||||
(value: 'vod' | 'itv' | 'series') => {
|
||||
selectedContentType.set(value);
|
||||
}
|
||||
),
|
||||
setSelectedCategory: jest.fn((value: string | null) => {
|
||||
selectedCategoryId.set(value);
|
||||
}),
|
||||
setSelectedItem: jest.fn((value: unknown) => {
|
||||
selectedItem.set(value);
|
||||
}),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
createLinkToPlayVod: jest.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerCollectionDetailComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: stalkerStore,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getPlaylistById: jest.fn(() => of(playlist)),
|
||||
getPortalFavorites: jest.fn(() => of([])),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(StalkerCollectionDetailComponent, {
|
||||
remove: {
|
||||
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubContentHeroComponent,
|
||||
StubStalkerInlineDetailComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StalkerCollectionDetailComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('opens legacy VOD is_series favorites through the lazy VOD-series flow', async () => {
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
buildCollectionItem({
|
||||
contentType: 'series',
|
||||
categoryId: 'series',
|
||||
stalkerItem: {
|
||||
id: '1507',
|
||||
title: 'Flagged Series',
|
||||
category_id: 'series',
|
||||
cmd: '/media/file_1507.mpg',
|
||||
is_series: '1',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
|
||||
'vod'
|
||||
);
|
||||
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod');
|
||||
expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '1507',
|
||||
is_series: true,
|
||||
})
|
||||
);
|
||||
expect(fixture.componentInstance.inlineDetail()).toEqual(
|
||||
expect.objectContaining({
|
||||
categoryId: 'vod',
|
||||
isSeries: true,
|
||||
vodDetailsItem: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps regular Stalker series favorites in regular series mode', async () => {
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
buildCollectionItem({
|
||||
contentType: 'series',
|
||||
categoryId: 'series',
|
||||
stalkerItem: {
|
||||
id: '30001',
|
||||
series_id: '30001',
|
||||
title: 'Regular Series',
|
||||
category_id: 'series',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
|
||||
'series'
|
||||
);
|
||||
expect(fixture.componentInstance.inlineDetail()).toEqual(
|
||||
expect.objectContaining({
|
||||
categoryId: 'series',
|
||||
isSeries: false,
|
||||
vodDetailsItem: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps embedded VOD series favorites in the embedded VOD-series path', async () => {
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
buildCollectionItem({
|
||||
contentType: 'series',
|
||||
categoryId: 'series',
|
||||
stalkerItem: {
|
||||
id: '20001',
|
||||
title: 'Embedded Series',
|
||||
category_id: 'series',
|
||||
cmd: '/media/file_20001.mpg',
|
||||
series: [1, 2],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
|
||||
'vod'
|
||||
);
|
||||
expect(fixture.componentInstance.inlineDetail()).toEqual(
|
||||
expect.objectContaining({
|
||||
categoryId: 'vod',
|
||||
isSeries: false,
|
||||
vodDetailsItem: null,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
fixture.componentInstance.inlineDetail().seriesItem?.series
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
function buildCollectionItem(
|
||||
overrides: Partial<UnifiedCollectionItem>
|
||||
): UnifiedCollectionItem {
|
||||
return {
|
||||
uid: 'stalker::stalker-1::item-1',
|
||||
name: 'Item',
|
||||
contentType: 'movie',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker Portal',
|
||||
stalkerId: 'item-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function settleDetail(
|
||||
fixture: ComponentFixture<StalkerCollectionDetailComponent>
|
||||
): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import {
|
||||
buildStalkerStateItem,
|
||||
toStalkerCategoryId,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
buildStalkerSelectedVodItem,
|
||||
clearStalkerDetailViewState,
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
createStalkerDetailViewState,
|
||||
createStalkerInlineDetailState,
|
||||
isSelectedStalkerVodFavorite,
|
||||
isStalkerSeriesFlag,
|
||||
StalkerSelectedVodItem,
|
||||
StalkerStore,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { Playlist, StalkerPortalItem, VodDetailsItem } from 'shared-interfaces';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
||||
|
||||
interface StalkerCollectionStateSnapshot {
|
||||
currentPlaylist: Playlist | undefined;
|
||||
selectedContentType: 'vod' | 'itv' | 'series';
|
||||
selectedCategoryId: string | null | undefined;
|
||||
selectedItem: unknown;
|
||||
}
|
||||
|
||||
type StalkerDetailCategory = 'vod' | 'series';
|
||||
|
||||
interface StalkerCollectionDetailMode {
|
||||
category: StalkerDetailCategory;
|
||||
selectedContentType: StalkerDetailCategory;
|
||||
hasEmbeddedSeries: boolean;
|
||||
needsSeriesFetch: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-collection-detail',
|
||||
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
|
||||
template: `
|
||||
@if (inlineDetail().categoryId) {
|
||||
<app-stalker-inline-detail
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(backClicked)="closeRequested.emit()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-content-hero [isLoading]="true" />
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class StalkerCollectionDetailComponent {
|
||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly originalState = this.captureStoreState();
|
||||
private readonly favoritesRefresh = createRefreshTrigger();
|
||||
|
||||
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
|
||||
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
||||
readonly isSelectedVodFavorite = signal(false);
|
||||
readonly detailCategoryOverride = signal<StalkerDetailCategory | null>(
|
||||
null
|
||||
);
|
||||
readonly inlineDetail = computed(() =>
|
||||
createStalkerInlineDetailState(
|
||||
this.itemDetails(),
|
||||
this.vodDetailsItem(),
|
||||
this.detailCategoryOverride()
|
||||
)
|
||||
);
|
||||
|
||||
readonly portalFavorites = createPortalFavoritesResource(
|
||||
this.playlistsService,
|
||||
() => this.stalkerStore.currentPlaylist()?._id,
|
||||
() => this.favoritesRefresh.refreshVersion()
|
||||
);
|
||||
|
||||
private initRequestId = 0;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.portalFavorites.value();
|
||||
this.syncSelectedVodFavorite();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const item = this.item();
|
||||
untracked(() => {
|
||||
void this.prepareDetail(item);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
void this.stalkerStore.setCurrentPlaylist(
|
||||
this.originalState.currentPlaylist
|
||||
);
|
||||
this.stalkerStore.setSelectedContentType(
|
||||
this.originalState.selectedContentType
|
||||
);
|
||||
this.stalkerStore.setSelectedCategory(
|
||||
this.originalState.selectedCategoryId
|
||||
);
|
||||
this.stalkerStore.setSelectedItem(
|
||||
this.originalState.selectedItem as never
|
||||
);
|
||||
}
|
||||
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
this.stalkerStore.createLinkToPlayVod(
|
||||
item.cmd,
|
||||
item.data.info?.name,
|
||||
item.data.info?.movie_image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onVodFavoriteToggled(event: {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}): void {
|
||||
toggleStalkerVodFavorite(event, {
|
||||
addToFavorites: (item, onDone) =>
|
||||
this.stalkerStore.addToFavorites(
|
||||
item as StalkerPortalItem,
|
||||
onDone
|
||||
),
|
||||
removeFromFavorites: (favoriteId, onDone) =>
|
||||
this.stalkerStore.removeFromFavorites(favoriteId, onDone),
|
||||
onComplete: () => {
|
||||
this.favoritesRefresh.refresh();
|
||||
this.syncSelectedVodFavorite();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async prepareDetail(
|
||||
item: UnifiedCollectionItem | null
|
||||
): Promise<void> {
|
||||
const requestId = ++this.initRequestId;
|
||||
|
||||
if (!item) {
|
||||
this.clearLocalDetailState();
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = await this.loadPlaylist(item.playlistId);
|
||||
if (requestId !== this.initRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playlist?.macAddress || !playlist.portalUrl) {
|
||||
this.clearLocalDetailState();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stalkerStore.setCurrentPlaylist(playlist);
|
||||
if (requestId !== this.initRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stalkerItem = this.resolveStalkerItem(item);
|
||||
const detailMode = this.resolveDetailMode(item, stalkerItem);
|
||||
const itemDetails = buildStalkerSelectedVodItem(
|
||||
stalkerItem as never,
|
||||
detailMode.needsSeriesFetch
|
||||
);
|
||||
|
||||
this.detailCategoryOverride.set(detailMode.category);
|
||||
this.stalkerStore.setSelectedContentType(detailMode.selectedContentType);
|
||||
this.stalkerStore.setSelectedCategory(
|
||||
this.resolveSelectedCategory(item, stalkerItem, detailMode)
|
||||
);
|
||||
this.stalkerStore.setSelectedItem(itemDetails);
|
||||
this.itemDetails.set(itemDetails);
|
||||
|
||||
if (
|
||||
detailMode.selectedContentType === 'vod' &&
|
||||
!detailMode.hasEmbeddedSeries &&
|
||||
!detailMode.needsSeriesFetch
|
||||
) {
|
||||
const detailViewState = createStalkerDetailViewState(
|
||||
itemDetails,
|
||||
playlist._id
|
||||
);
|
||||
this.itemDetails.set(detailViewState.itemDetails);
|
||||
this.vodDetailsItem.set(detailViewState.vodDetailsItem);
|
||||
this.syncSelectedVodFavorite();
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = clearStalkerDetailViewState();
|
||||
this.vodDetailsItem.set(cleared.vodDetailsItem);
|
||||
this.isSelectedVodFavorite.set(false);
|
||||
}
|
||||
|
||||
private captureStoreState(): StalkerCollectionStateSnapshot {
|
||||
return {
|
||||
currentPlaylist:
|
||||
(this.stalkerStore.currentPlaylist() as Playlist | undefined) ??
|
||||
undefined,
|
||||
selectedContentType: this.stalkerStore.selectedContentType(),
|
||||
selectedCategoryId: this.stalkerStore.selectedCategoryId(),
|
||||
selectedItem: this.stalkerStore.selectedItem(),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPlaylist(playlistId: string): Promise<Playlist | null> {
|
||||
try {
|
||||
return (
|
||||
(await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistId)
|
||||
)) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveStalkerItem(item: UnifiedCollectionItem): StalkerPortalItem {
|
||||
return buildStalkerStateItem(
|
||||
item.stalkerItem as StalkerPortalItem | undefined,
|
||||
{
|
||||
id:
|
||||
item.stalkerId ??
|
||||
item.uid.split('::')[item.uid.split('::').length - 1] ??
|
||||
item.uid,
|
||||
title: item.name,
|
||||
type: item.contentType,
|
||||
category_id: item.categoryId,
|
||||
poster_url: item.posterUrl ?? item.logo ?? undefined,
|
||||
}
|
||||
) as StalkerPortalItem;
|
||||
}
|
||||
|
||||
private resolveDetailMode(
|
||||
item: UnifiedCollectionItem,
|
||||
stalkerItem: StalkerPortalItem
|
||||
): StalkerCollectionDetailMode {
|
||||
const hasEmbeddedSeries = Array.isArray(
|
||||
(stalkerItem as { series?: unknown[] }).series
|
||||
)
|
||||
? (stalkerItem as { series?: unknown[] }).series!.length > 0
|
||||
: false;
|
||||
const isVodSeries = isStalkerSeriesFlag(
|
||||
(stalkerItem as { is_series?: unknown }).is_series
|
||||
);
|
||||
const isRegularSeries =
|
||||
item.contentType === 'series' && !hasEmbeddedSeries && !isVodSeries;
|
||||
const selectedContentType: StalkerDetailCategory = isRegularSeries
|
||||
? 'series'
|
||||
: 'vod';
|
||||
|
||||
return {
|
||||
category: selectedContentType,
|
||||
selectedContentType,
|
||||
hasEmbeddedSeries,
|
||||
needsSeriesFetch:
|
||||
selectedContentType === 'vod' &&
|
||||
!hasEmbeddedSeries &&
|
||||
isVodSeries,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSelectedCategory(
|
||||
item: UnifiedCollectionItem,
|
||||
stalkerItem: StalkerPortalItem,
|
||||
detailMode: StalkerCollectionDetailMode
|
||||
): string | number {
|
||||
const categoryId =
|
||||
item.categoryId ??
|
||||
(stalkerItem as { category_id?: string | number }).category_id;
|
||||
|
||||
if (
|
||||
detailMode.selectedContentType === 'vod' &&
|
||||
String(categoryId ?? '').toLowerCase() === 'series'
|
||||
) {
|
||||
return 'vod';
|
||||
}
|
||||
|
||||
return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType);
|
||||
}
|
||||
|
||||
private syncSelectedVodFavorite(): void {
|
||||
this.isSelectedVodFavorite.set(
|
||||
isSelectedStalkerVodFavorite(
|
||||
this.vodDetailsItem(),
|
||||
this.portalFavorites.value() ?? []
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private clearLocalDetailState(): void {
|
||||
const cleared = clearStalkerDetailViewState();
|
||||
this.itemDetails.set(cleared.itemDetails);
|
||||
this.vodDetailsItem.set(cleared.vodDetailsItem);
|
||||
this.detailCategoryOverride.set(null);
|
||||
this.isSelectedVodFavorite.set(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
UnifiedCollectionDetailDirective,
|
||||
UnifiedCollectionPageComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import { routeParamSignal } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-collection-route',
|
||||
imports: [
|
||||
StalkerCollectionDetailComponent,
|
||||
UnifiedCollectionDetailDirective,
|
||||
UnifiedCollectionPageComponent,
|
||||
],
|
||||
template: `
|
||||
<app-unified-collection-page
|
||||
[mode]="mode()"
|
||||
[portalType]="portalType()"
|
||||
[playlistId]="playlistId()"
|
||||
>
|
||||
<ng-template unifiedCollectionDetail let-item let-close="close">
|
||||
<app-stalker-collection-detail
|
||||
[item]="item"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
</ng-template>
|
||||
</app-unified-collection-page>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerCollectionRouteComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly mode = input<'favorites' | 'recent'>('favorites');
|
||||
readonly portalType = input('stalker');
|
||||
readonly playlistId = routeParamSignal<string | undefined>(
|
||||
this.route,
|
||||
'id',
|
||||
(value) => value ?? undefined
|
||||
);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
@let isFavorite = isFavorite$ | async;
|
||||
@if (isFavorite === false) {
|
||||
<button class="favorite-btn" (click)="addToFavorites()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="favorite-btn favorite-btn--active" (click)="removeFromFavorites()">
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
.favorite-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: rgba(109, 109, 110, 0.7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: rgba(229, 9, 20, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 9, 20, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { of } from 'rxjs';
|
||||
import { FavoritesButtonComponent } from './stalker-favorites-button.component';
|
||||
|
||||
describe('FavoritesButtonComponent', () => {
|
||||
let fixture: ComponentFixture<FavoritesButtonComponent>;
|
||||
let selectedContentType: ReturnType<typeof signal<'vod' | 'series'>>;
|
||||
let addToFavorites: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedContentType = signal<'vod' | 'series'>('series');
|
||||
addToFavorites = jest.fn((_item: unknown, onDone?: () => void) => {
|
||||
onDone?.();
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
FavoritesButtonComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
currentPlaylist: signal({ _id: 'stalker-1' }),
|
||||
selectedContentType,
|
||||
addToFavorites,
|
||||
removeFromFavorites: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getPortalFavorites: jest.fn(() => of([])),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FavoritesButtonComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('preserves VOD-series favorite payload shape from the VOD series view', () => {
|
||||
selectedContentType.set('vod');
|
||||
fixture.componentRef.setInput('itemId', '1507');
|
||||
fixture.componentRef.setInput('item', {
|
||||
id: '1507',
|
||||
cmd: '/media/file_1507.mpg',
|
||||
category_id: '2001',
|
||||
is_series: '1',
|
||||
info: {
|
||||
name: 'VOD Flagged Series',
|
||||
movie_image: 'poster.jpg',
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.addToFavorites();
|
||||
|
||||
expect(addToFavorites).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '1507',
|
||||
category_id: '2001',
|
||||
is_series: true,
|
||||
title: 'VOD Flagged Series',
|
||||
cover: 'poster.jpg',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(addToFavorites.mock.calls[0][0]).not.toHaveProperty('series_id');
|
||||
});
|
||||
|
||||
it('preserves embedded VOD series arrays in favorite payloads', () => {
|
||||
selectedContentType.set('vod');
|
||||
fixture.componentRef.setInput('itemId', '20001');
|
||||
fixture.componentRef.setInput('item', {
|
||||
id: '20001',
|
||||
cmd: '/media/file_20001.mpg',
|
||||
category_id: '2001',
|
||||
series: [1, 2],
|
||||
info: {
|
||||
name: 'Embedded Series',
|
||||
movie_image: 'embedded.jpg',
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.addToFavorites();
|
||||
|
||||
expect(addToFavorites).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '20001',
|
||||
category_id: '2001',
|
||||
series: [1, 2],
|
||||
title: 'Embedded Series',
|
||||
cover: 'embedded.jpg',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(addToFavorites.mock.calls[0][0]).not.toHaveProperty('series_id');
|
||||
});
|
||||
|
||||
it('writes regular Stalker series favorites with series_id', () => {
|
||||
selectedContentType.set('series');
|
||||
fixture.componentRef.setInput('itemId', '30001');
|
||||
fixture.componentRef.setInput('item', {
|
||||
id: '30001',
|
||||
cmd: '/media/file_30001.mpg',
|
||||
info: {
|
||||
name: 'Regular Series',
|
||||
movie_image: 'series.jpg',
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.addToFavorites();
|
||||
|
||||
expect(addToFavorites).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
series_id: '30001',
|
||||
category_id: 'series',
|
||||
title: 'Regular Series',
|
||||
cover: 'series.jpg',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
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 '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
matchesFavoriteById,
|
||||
normalizeStalkerSeriesFlag,
|
||||
StalkerSelectedVodItem,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
type FavoriteButtonItem = StalkerSelectedVodItem & {
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
cover?: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorites-button',
|
||||
templateUrl: './stalker-favorites-button.component.html',
|
||||
styleUrls: ['./stalker-favorites-button.component.scss'],
|
||||
imports: [AsyncPipe, MatIconModule, TranslateModule],
|
||||
})
|
||||
export class FavoritesButtonComponent {
|
||||
private playlistService = inject(PlaylistsService);
|
||||
private stalkerStore = inject(StalkerStore);
|
||||
|
||||
readonly itemId = input.required<string>();
|
||||
readonly item = input.required<FavoriteButtonItem>();
|
||||
|
||||
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
|
||||
|
||||
readonly isFavorite$ = this.favoritesChanged$.pipe(
|
||||
switchMap(() =>
|
||||
this.playlistService.getPortalFavorites(
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
)
|
||||
),
|
||||
map((favorites) =>
|
||||
favorites.some((favorite) =>
|
||||
matchesFavoriteById(favorite, this.itemId())
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
removeFromFavorites() {
|
||||
this.stalkerStore.removeFromFavorites(this.itemId(), () => {
|
||||
this.favoritesChanged$.next();
|
||||
});
|
||||
}
|
||||
|
||||
addToFavorites() {
|
||||
const item = this.item();
|
||||
const favoriteBase = {
|
||||
...item,
|
||||
title: item.info?.name ?? item.name ?? item.o_name,
|
||||
cover: item.info?.movie_image ?? item.cover,
|
||||
added_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.stalkerStore.selectedContentType() === 'vod') {
|
||||
this.stalkerStore.addToFavorites(
|
||||
{
|
||||
...favoriteBase,
|
||||
id: item.id,
|
||||
category_id: item.category_id ?? 'vod',
|
||||
series: item.series,
|
||||
is_series: normalizeStalkerSeriesFlag(item.is_series),
|
||||
},
|
||||
() => {
|
||||
this.favoritesChanged$.next();
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.stalkerStore.addToFavorites(
|
||||
{
|
||||
...favoriteBase,
|
||||
series_id: item.id,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component';
|
||||
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
|
||||
import { provideStalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
|
||||
|
||||
type ComponentLoader = NonNullable<Route['loadComponent']>;
|
||||
|
||||
const loadDownloadsComponent: ComponentLoader = () =>
|
||||
import('@iptvnator/portal/downloads/feature').then(
|
||||
(c) => c.DownloadsComponent
|
||||
);
|
||||
|
||||
const loadStalkerLiveStreamLayoutComponent: ComponentLoader = () =>
|
||||
import('./stalker-live-stream-layout/stalker-live-stream-layout.component').then(
|
||||
(c) => c.StalkerLiveStreamLayoutComponent
|
||||
);
|
||||
|
||||
const loadCategoryContentViewComponent: ComponentLoader = () =>
|
||||
import('@iptvnator/portal/catalog/feature').then(
|
||||
(c) => c.CategoryContentViewComponent
|
||||
);
|
||||
|
||||
const loadStalkerCollectionRouteComponent: ComponentLoader = () =>
|
||||
import('./stalker-collection-route.component').then(
|
||||
(c) => c.StalkerCollectionRouteComponent
|
||||
);
|
||||
|
||||
const loadStalkerSearchComponent: ComponentLoader = () =>
|
||||
import('./stalker-search/stalker-search.component').then(
|
||||
(c) => c.StalkerSearchComponent
|
||||
);
|
||||
|
||||
export function createStalkerRoutes(): Route[] {
|
||||
return [
|
||||
{
|
||||
path: 'stalker/:id',
|
||||
providers: provideStalkerWorkspaceRouteSession(),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'vod',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'vod',
|
||||
providers: [
|
||||
...provideStalkerCatalogFacade(),
|
||||
{
|
||||
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
|
||||
useValue: StalkerCatalogDetailComponent,
|
||||
},
|
||||
],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
api: 'stalker',
|
||||
contentType: 'vod',
|
||||
},
|
||||
loadComponent: loadCategoryContentViewComponent,
|
||||
},
|
||||
{
|
||||
path: ':categoryId',
|
||||
data: {
|
||||
api: 'stalker',
|
||||
contentType: 'vod',
|
||||
},
|
||||
loadComponent: loadCategoryContentViewComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'itv',
|
||||
loadComponent: loadStalkerLiveStreamLayoutComponent,
|
||||
},
|
||||
{
|
||||
path: 'series',
|
||||
providers: [
|
||||
...provideStalkerCatalogFacade(),
|
||||
{
|
||||
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
|
||||
useValue: StalkerCatalogDetailComponent,
|
||||
},
|
||||
],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
api: 'stalker',
|
||||
contentType: 'series',
|
||||
},
|
||||
loadComponent: loadCategoryContentViewComponent,
|
||||
},
|
||||
{
|
||||
path: ':categoryId',
|
||||
data: {
|
||||
api: 'stalker',
|
||||
contentType: 'series',
|
||||
},
|
||||
loadComponent: loadCategoryContentViewComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'favorites',
|
||||
loadComponent: loadStalkerCollectionRouteComponent,
|
||||
data: { mode: 'favorites', portalType: 'stalker' },
|
||||
},
|
||||
{
|
||||
path: 'recent',
|
||||
loadComponent: loadStalkerCollectionRouteComponent,
|
||||
data: { mode: 'recent', portalType: 'stalker' },
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
loadComponent: loadStalkerSearchComponent,
|
||||
},
|
||||
{
|
||||
path: 'downloads',
|
||||
loadComponent: loadDownloadsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
@if (categoryId() === 'vod') {
|
||||
@if ((seriesItem()?.series?.length ?? 0) > 0) {
|
||||
<!-- VOD item with embedded series (vclub) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="seriesItem()"
|
||||
(backClicked)="onBackClicked()"
|
||||
/>
|
||||
} @else if (isSeries()) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view (backClicked)="onBackClicked()" />
|
||||
} @else if (vodDetailsItem()) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem()"
|
||||
[isFavorite]="isFavorite()"
|
||||
[externalPlayback]="externalPlayback.activeSession()"
|
||||
(playClicked)="onPlayClicked($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(backClicked)="onBackClicked()"
|
||||
/>
|
||||
}
|
||||
} @else if (categoryId() === 'series') {
|
||||
<app-stalker-series-view (backClicked)="onBackClicked()" />
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
|
||||
import { VodDetailsComponent } from '@iptvnator/ui/playback';
|
||||
import { VodDetailsItem } from 'shared-interfaces';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-inline-detail',
|
||||
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
|
||||
templateUrl: './stalker-inline-detail.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class StalkerInlineDetailComponent {
|
||||
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
readonly categoryId = input<'vod' | 'series' | null>(null);
|
||||
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
|
||||
readonly isSeries = input<boolean>(false);
|
||||
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
|
||||
readonly isFavorite = input<boolean>(false);
|
||||
|
||||
readonly backClicked = output<void>();
|
||||
readonly playClicked = output<VodDetailsItem>();
|
||||
readonly favoriteToggled = output<{
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}>();
|
||||
|
||||
onBackClicked() {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
|
||||
onPlayClicked(item: VodDetailsItem) {
|
||||
this.playClicked.emit(item);
|
||||
}
|
||||
|
||||
onFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }) {
|
||||
this.favoriteToggled.emit(event);
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
@let isEmbeddedPlayer = usesEmbeddedPlayer();
|
||||
|
||||
@if (stalkerStore.selectedCategoryId()) {
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header category-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">
|
||||
{{ selectedCategoryTitle() }}
|
||||
</h2>
|
||||
@if (visibleChannels().length > 0) {
|
||||
<span class="category-subtitle"
|
||||
>{{ visibleChannels().length }}
|
||||
{{
|
||||
visibleChannels().length === 1 ? 'item' : 'items'
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div #scrollContainer class="channels-list">
|
||||
@if (searchTerm() && visibleChannels().length === 0) {
|
||||
<div class="empty-search-state">
|
||||
<app-portal-empty-state
|
||||
icon="search_off"
|
||||
[message]="'CHANNELS.NO_SEARCH_RESULTS' | translate"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
@for (item of visibleChannels(); track item.id ?? $index) {
|
||||
<app-channel-list-item
|
||||
[name]="item.o_name || item.name || ''"
|
||||
[logo]="item.logo"
|
||||
[selected]="isSelectedChannel(item)"
|
||||
[epgProgram]="
|
||||
epgPreviewPrograms.get(
|
||||
normalizeStalkerEntityId(item.id)
|
||||
) ?? null
|
||||
"
|
||||
[progressPercentage]="
|
||||
currentProgramsProgress.get(
|
||||
normalizeStalkerEntityId(item.id)
|
||||
) ?? 0
|
||||
"
|
||||
[showFavoriteButton]="true"
|
||||
[showProgramInfoButton]="false"
|
||||
[isFavorite]="
|
||||
favorites.get(normalizeStalkerEntityId(item.id)) ??
|
||||
false
|
||||
"
|
||||
(clicked)="playChannel(item)"
|
||||
(favoriteToggled)="toggleFavorite(item)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isLoadingMore()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="content-container">
|
||||
@if (!stalkerStore.selectedCategoryId()) {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
[message]="'PORTALS.SELECT_CATEGORY' | translate"
|
||||
/>
|
||||
} @else if (stalkerStore.selectedItem()) {
|
||||
@if (streamUrl() && isEmbeddedPlayer) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view [streamUrl]="streamUrl()" />
|
||||
</div>
|
||||
}
|
||||
<div class="epg">
|
||||
<div class="epg-content">
|
||||
@if (isLoadingEpg()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
} @else {
|
||||
<app-epg-list
|
||||
[controlledChannel]="controlledChannel()"
|
||||
[controlledPrograms]="activeEpgPrograms()"
|
||||
[controlledArchiveDays]="0"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
@use '../../../../../../ui/styles/portal-layout' as portal;
|
||||
@use '../../../../../../ui/styles/panel-header' as panel;
|
||||
@use '../../../../../../ui/styles/portal-sidebar';
|
||||
|
||||
@include portal.live-layout;
|
||||
|
||||
.category-content-header {
|
||||
@include panel.standard-panel-header(
|
||||
$padding: 8px 12px,
|
||||
$gap: 8px,
|
||||
$min-height: 48px,
|
||||
$border-color: var(--app-separator)
|
||||
);
|
||||
app-region: drag;
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
@include panel.standard-panel-meta(8px);
|
||||
}
|
||||
|
||||
.category-title {
|
||||
@include panel.standard-panel-title(0.94rem);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.category-subtitle {
|
||||
@include panel.standard-panel-subtitle(
|
||||
$font-size: 0.72rem,
|
||||
$padding: 2px 8px,
|
||||
$color: var(--mat-sys-on-surface-variant),
|
||||
$background: var(--mat-sys-surface-container)
|
||||
);
|
||||
}
|
||||
|
||||
.channels-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.load-more-epg {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@container (max-width: 350px) {
|
||||
.channels-list {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
import { Component, Directive, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { EpgListComponent } from '@iptvnator/ui/epg';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from 'components';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { WebPlayerViewComponent } from 'shared-portals';
|
||||
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-item',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubChannelListItemComponent {
|
||||
readonly name = input('');
|
||||
readonly logo = input<string | null | undefined>(null);
|
||||
readonly selected = input(false);
|
||||
readonly epgProgram = input<unknown>(null);
|
||||
readonly progressPercentage = input(0);
|
||||
readonly showFavoriteButton = input(false);
|
||||
readonly showProgramInfoButton = input(false);
|
||||
readonly isFavorite = input(false);
|
||||
readonly clicked = output<void>();
|
||||
readonly favoriteToggled = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-list',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubEpgListComponent {
|
||||
readonly controlledChannel = input<unknown>(null);
|
||||
readonly controlledPrograms = input<EpgProgram[] | null>(null);
|
||||
readonly controlledArchiveDays = input<number | null>(null);
|
||||
readonly archivePlaybackAvailable = input<boolean | null>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-empty-state',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubPortalEmptyStateComponent {
|
||||
readonly icon = input('');
|
||||
readonly message = input('');
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
class StubResizableDirective {}
|
||||
|
||||
describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
|
||||
let component: StalkerLiveStreamLayoutComponent;
|
||||
let fetchChannelEpg: jest.Mock;
|
||||
let ensureBulkItvEpg: jest.Mock;
|
||||
let resolveItvPlayback: jest.Mock;
|
||||
|
||||
const playlist = signal({
|
||||
_id: 'playlist-1',
|
||||
title: 'Demo Stalker',
|
||||
});
|
||||
const selectedCategoryId = signal<string | null>('1001');
|
||||
const searchPhrase = signal('');
|
||||
const itvChannels = signal([
|
||||
{
|
||||
id: '10001',
|
||||
cmd: 'ffrt4://itv/10001',
|
||||
name: 'Alpha TV',
|
||||
o_name: 'Alpha TV',
|
||||
logo: 'alpha.png',
|
||||
},
|
||||
{
|
||||
id: '10002',
|
||||
cmd: 'ffrt4://itv/10002',
|
||||
name: 'Beta TV',
|
||||
o_name: 'Beta TV',
|
||||
logo: 'beta.png',
|
||||
},
|
||||
]);
|
||||
const selectedItvId = signal<string | undefined>('10001');
|
||||
const selectedItem = signal<{
|
||||
id: string;
|
||||
cmd: string;
|
||||
name: string;
|
||||
o_name: string;
|
||||
logo: string;
|
||||
} | null>(itvChannels()[0]);
|
||||
const selectedItvEpgPrograms = signal<EpgProgram[]>([]);
|
||||
const bulkItvEpgByChannel = signal<Record<string, EpgProgram[]>>({});
|
||||
const bulkItvEpgLoaded = signal(false);
|
||||
const bulkItvEpgPlaylistId = signal<string | null>(null);
|
||||
const bulkItvEpgPeriodHours = signal<number | null>(null);
|
||||
const isLoadingBulkItvEpg = signal(false);
|
||||
|
||||
const stalkerStore = {
|
||||
getSelectedCategoryName: signal('News'),
|
||||
itvChannels,
|
||||
searchPhrase,
|
||||
hasMoreChannels: signal(false),
|
||||
selectedItvId,
|
||||
currentPlaylist: playlist,
|
||||
selectedItvEpgPrograms,
|
||||
bulkItvEpgByChannel,
|
||||
bulkItvEpgLoaded,
|
||||
bulkItvEpgPlaylistId,
|
||||
bulkItvEpgPeriodHours,
|
||||
isLoadingBulkItvEpg,
|
||||
selectedCategoryId,
|
||||
selectedItem,
|
||||
selectedContentType: signal<'itv' | 'vod' | 'series'>('itv'),
|
||||
page: signal(0),
|
||||
setItvChannels: jest.fn(),
|
||||
setPage: jest.fn(),
|
||||
setSelectedItem: jest.fn((item) => {
|
||||
selectedItem.set(item);
|
||||
selectedItvId.set(String(item.id));
|
||||
selectedItvEpgPrograms.set(
|
||||
bulkItvEpgByChannel()[String(item.id)] ?? []
|
||||
);
|
||||
}),
|
||||
resolveItvPlayback: jest.fn(),
|
||||
fetchChannelEpg: jest.fn(),
|
||||
ensureBulkItvEpg: jest.fn(),
|
||||
clearBulkItvEpgCache: jest.fn(() => {
|
||||
bulkItvEpgByChannel.set({});
|
||||
bulkItvEpgLoaded.set(false);
|
||||
bulkItvEpgPlaylistId.set(null);
|
||||
bulkItvEpgPeriodHours.set(null);
|
||||
selectedItvEpgPrograms.set([]);
|
||||
}),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
};
|
||||
|
||||
const playlistService = {
|
||||
getPortalFavorites: jest.fn(() => of([])),
|
||||
};
|
||||
const portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn(() => true),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchChannelEpg = stalkerStore.fetchChannelEpg;
|
||||
ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg;
|
||||
resolveItvPlayback = stalkerStore.resolveItvPlayback;
|
||||
|
||||
selectedCategoryId.set('1001');
|
||||
searchPhrase.set('');
|
||||
selectedItvId.set('10001');
|
||||
selectedItem.set(itvChannels()[0]);
|
||||
selectedItvEpgPrograms.set([]);
|
||||
bulkItvEpgByChannel.set({});
|
||||
bulkItvEpgLoaded.set(false);
|
||||
bulkItvEpgPlaylistId.set(null);
|
||||
bulkItvEpgPeriodHours.set(null);
|
||||
isLoadingBulkItvEpg.set(false);
|
||||
|
||||
resolveItvPlayback.mockReset();
|
||||
resolveItvPlayback.mockResolvedValue({
|
||||
streamUrl: 'https://example.com/alpha.m3u8',
|
||||
});
|
||||
fetchChannelEpg.mockReset();
|
||||
fetchChannelEpg.mockResolvedValue([]);
|
||||
ensureBulkItvEpg.mockReset();
|
||||
ensureBulkItvEpg.mockImplementation(async () => {
|
||||
const bulkPrograms = {
|
||||
'10001': [buildProgram('10001', 'Current Show')],
|
||||
'10002': [buildProgram('10002', 'Next Channel Show')],
|
||||
};
|
||||
bulkItvEpgByChannel.set(bulkPrograms);
|
||||
bulkItvEpgLoaded.set(true);
|
||||
bulkItvEpgPlaylistId.set('playlist-1');
|
||||
bulkItvEpgPeriodHours.set(168);
|
||||
selectedItvEpgPrograms.set(
|
||||
bulkPrograms[selectedItvId() ?? ''] ?? []
|
||||
);
|
||||
});
|
||||
stalkerStore.setItvChannels.mockClear();
|
||||
stalkerStore.setPage.mockClear();
|
||||
stalkerStore.setSelectedItem.mockClear();
|
||||
stalkerStore.clearBulkItvEpgCache.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerLiveStreamLayoutComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: StalkerStore, useValue: stalkerStore },
|
||||
{ provide: PlaylistsService, useValue: playlistService },
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((value: string) => value),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(StalkerLiveStreamLayoutComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
ChannelListItemComponent,
|
||||
EpgListComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubChannelListItemComponent,
|
||||
StubEpgListComponent,
|
||||
StubPortalEmptyStateComponent,
|
||||
StubResizableDirective,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
StubWebPlayerViewComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('renders the controlled epg list and removes the load-more button', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.load-more-epg')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps row previews empty before bulk epg is loaded', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.epgPreviewPrograms.size).toBe(0);
|
||||
expect(fetchChannelEpg).not.toHaveBeenCalled();
|
||||
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('derives row previews from cached bulk epg after first playback', async () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.playChannel(itvChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.epgPreviewPrograms.get('10001')?.title).toBe(
|
||||
'Current Show'
|
||||
);
|
||||
expect(component.epgPreviewPrograms.get('10002')?.title).toBe(
|
||||
'Next Channel Show'
|
||||
);
|
||||
expect(fetchChannelEpg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensures bulk EPG only once across channel switches for the same playlist', async () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.playChannel(itvChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
await component.playChannel(itvChannels()[1]);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(ensureBulkItvEpg).toHaveBeenCalledTimes(1);
|
||||
expect(ensureBulkItvEpg).toHaveBeenCalledWith(168);
|
||||
});
|
||||
});
|
||||
|
||||
function buildProgram(channelId: string, title: string): EpgProgram {
|
||||
const startTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
|
||||
const stopTimestamp = startTimestamp + 30 * 60;
|
||||
|
||||
return {
|
||||
start: new Date(startTimestamp * 1000).toISOString(),
|
||||
stop: new Date(stopTimestamp * 1000).toISOString(),
|
||||
channel: channelId,
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
};
|
||||
}
|
||||
+624
@@ -0,0 +1,624 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnDestroy,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
ChannelListItemComponent,
|
||||
ResizableDirective,
|
||||
} from 'components';
|
||||
import { PlaylistsService } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import { EpgListComponent } from '@iptvnator/ui/epg';
|
||||
import { WebPlayerViewComponent } from 'shared-portals';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
createLogger,
|
||||
getAdjacentChannelItem,
|
||||
getChannelItemByNumber,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
StalkerFavoriteItem,
|
||||
StalkerItvChannel,
|
||||
StalkerStore,
|
||||
normalizeStalkerEntityId,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-live-stream-layout',
|
||||
templateUrl: './stalker-live-stream-layout.component.html',
|
||||
styleUrls: ['./stalker-live-stream-layout.component.scss'],
|
||||
imports: [
|
||||
ChannelListItemComponent,
|
||||
EpgListComponent,
|
||||
MatProgressSpinnerModule,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly logger = createLogger('StalkerLiveStream');
|
||||
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
|
||||
|
||||
/** Channels */
|
||||
readonly itvChannels = this.stalkerStore.itvChannels;
|
||||
readonly searchTerm = computed(() =>
|
||||
this.stalkerStore.searchPhrase().trim().toLowerCase()
|
||||
);
|
||||
readonly visibleChannels = computed(() => {
|
||||
const channels = this.itvChannels();
|
||||
const term = this.searchTerm();
|
||||
|
||||
if (!term) {
|
||||
return channels;
|
||||
}
|
||||
|
||||
return channels.filter((item) =>
|
||||
`${item.o_name ?? ''} ${item.name ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
);
|
||||
});
|
||||
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
|
||||
readonly isLoadingMore = signal(false);
|
||||
|
||||
readonly selectedChannelId = this.stalkerStore.selectedItvId;
|
||||
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
|
||||
|
||||
/** Player */
|
||||
readonly usesEmbeddedPlayer = computed(() =>
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
);
|
||||
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly streamUrl = computed(
|
||||
() => this.activePlayback()?.streamUrl ?? ''
|
||||
);
|
||||
|
||||
/** EPG */
|
||||
readonly fallbackEpgPrograms = signal<EpgProgram[]>([]);
|
||||
readonly isLoadingFallbackEpg = signal(false);
|
||||
readonly activeEpgPrograms = computed(() => {
|
||||
const bulkPrograms = this.stalkerStore.selectedItvEpgPrograms();
|
||||
return bulkPrograms.length > 0
|
||||
? bulkPrograms
|
||||
: this.fallbackEpgPrograms();
|
||||
});
|
||||
readonly currentProgram = computed(() =>
|
||||
this.findCurrentProgram(this.activeEpgPrograms())
|
||||
);
|
||||
readonly controlledChannel = computed<Channel | null>(() => {
|
||||
const selectedType = this.stalkerStore.selectedContentType();
|
||||
const selectedItem = this.stalkerStore.selectedItem();
|
||||
if (selectedType !== 'itv' || !selectedItem?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelId = normalizeStalkerEntityId(selectedItem.id);
|
||||
const channelName = selectedItem.o_name || selectedItem.name || '';
|
||||
|
||||
return {
|
||||
id: channelId,
|
||||
name: channelName,
|
||||
url: this.streamUrl() || String(selectedItem.cmd ?? ''),
|
||||
group: { title: '' },
|
||||
tvg: {
|
||||
id: channelId,
|
||||
name: channelName,
|
||||
url: '',
|
||||
logo: selectedItem.logo ?? '',
|
||||
rec: '',
|
||||
},
|
||||
http: {
|
||||
referrer: '',
|
||||
'user-agent': '',
|
||||
origin: '',
|
||||
},
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
};
|
||||
});
|
||||
readonly isLoadingEpg = computed(
|
||||
() =>
|
||||
this.stalkerStore.isLoadingBulkItvEpg() ||
|
||||
this.isLoadingFallbackEpg()
|
||||
);
|
||||
|
||||
/** Channel list EPG preview */
|
||||
readonly epgPreviewPrograms = new Map<string | number, EpgProgram>();
|
||||
readonly currentProgramsProgress = new Map<string | number, number>();
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
|
||||
/** Favorites */
|
||||
readonly favorites = new Map<string | number, boolean>();
|
||||
|
||||
/** Scroll */
|
||||
readonly scrollContainer = viewChild<ElementRef>('scrollContainer');
|
||||
private scrollListener: (() => void) | null = null;
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private epgLoadRequestId = 0;
|
||||
private lastPlaylistId: string | null | undefined = undefined;
|
||||
|
||||
constructor() {
|
||||
// Load favorites for current playlist
|
||||
this.playlistService
|
||||
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((favs) => {
|
||||
favs.forEach((fav: StalkerFavoriteItem) => {
|
||||
if (fav.id !== undefined) {
|
||||
this.favorites.set(
|
||||
normalizeStalkerEntityId(fav.id),
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reset channels/page on category change
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedCategoryId();
|
||||
this.stalkerStore.setItvChannels([]);
|
||||
this.stalkerStore.setPage(0);
|
||||
this.clearEpgPreviewMaps();
|
||||
this.epgLoadRequestId += 1;
|
||||
this.fallbackEpgPrograms.set([]);
|
||||
this.isLoadingFallbackEpg.set(false);
|
||||
});
|
||||
|
||||
// Reset loading state when channels load and keep preview data in sync with bulk EPG.
|
||||
effect(() => {
|
||||
const channels = this.visibleChannels();
|
||||
if (channels.length > 0) {
|
||||
this.isLoadingMore.set(false);
|
||||
if (!this.searchTerm()) {
|
||||
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncBulkEpgPreviews(channels);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id ?? null;
|
||||
if (playlistId === this.lastPlaylistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPlaylistId = playlistId;
|
||||
this.epgLoadRequestId += 1;
|
||||
this.fallbackEpgPrograms.set([]);
|
||||
this.isLoadingFallbackEpg.set(false);
|
||||
this.stalkerStore.clearBulkItvEpgCache();
|
||||
});
|
||||
|
||||
// Setup scroll listener when container becomes available
|
||||
effect(() => {
|
||||
const container = this.scrollContainer();
|
||||
if (container) {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!window.electron?.updateRemoteControlStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItem = this.stalkerStore.selectedItem();
|
||||
const selectedType = this.stalkerStore.selectedContentType();
|
||||
const channels = this.visibleChannels();
|
||||
|
||||
if (selectedType !== 'itv' || !selectedItem?.id) {
|
||||
window.electron.updateRemoteControlStatus({
|
||||
portal: 'stalker',
|
||||
isLiveView: false,
|
||||
supportsVolume: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = channels.findIndex(
|
||||
(item) => Number(item.id) === Number(selectedItem.id)
|
||||
);
|
||||
const currentProgram = this.currentProgram();
|
||||
|
||||
window.electron.updateRemoteControlStatus({
|
||||
portal: 'stalker',
|
||||
isLiveView: true,
|
||||
channelName: selectedItem.o_name || selectedItem.name,
|
||||
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
|
||||
epgTitle: currentProgram?.title,
|
||||
epgStart: currentProgram?.start,
|
||||
epgEnd: currentProgram?.stop,
|
||||
supportsVolume: false,
|
||||
});
|
||||
});
|
||||
|
||||
if (window.electron?.onChannelChange) {
|
||||
const unsubscribe = window.electron.onChannelChange(
|
||||
(data: { direction: 'up' | 'down' }) => {
|
||||
this.handleRemoteChannelChange(data.direction);
|
||||
}
|
||||
);
|
||||
if (typeof unsubscribe === 'function') {
|
||||
this.unsubscribeRemoteChannelChange = unsubscribe;
|
||||
}
|
||||
}
|
||||
if (window.electron?.onRemoteControlCommand) {
|
||||
const unsubscribe = window.electron.onRemoteControlCommand(
|
||||
(command) => {
|
||||
this.handleRemoteControlCommand(command);
|
||||
}
|
||||
);
|
||||
if (typeof unsubscribe === 'function') {
|
||||
this.unsubscribeRemoteCommand = unsubscribe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
this.removeScrollListener();
|
||||
}
|
||||
|
||||
isSelectedChannel(item: StalkerItvChannel): boolean {
|
||||
return (
|
||||
this.selectedChannelId() === this.normalizeStalkerEntityId(item.id)
|
||||
);
|
||||
}
|
||||
|
||||
async playChannel(item: StalkerItvChannel) {
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
|
||||
try {
|
||||
const playback = await this.stalkerStore.resolveItvPlayback(item);
|
||||
void this.loadEpgForChannel(item);
|
||||
|
||||
if (this.usesEmbeddedPlayer()) {
|
||||
this.activePlayback.set(playback);
|
||||
} else {
|
||||
void this.portalPlayer.openResolvedPlayback(playback, true);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Playback failed', error);
|
||||
const errorMessage =
|
||||
error?.message === 'nothing_to_play'
|
||||
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
|
||||
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
|
||||
this.snackBar.open(errorMessage, null, { duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
category_id: 'itv',
|
||||
title: item.o_name || item.name,
|
||||
cover: item.logo,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
this.favorites.set(itemId, true);
|
||||
}
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
this.isLoadingMore.set(true);
|
||||
const nextPage = this.stalkerStore.page() + 1;
|
||||
this.stalkerStore.setPage(nextPage);
|
||||
}
|
||||
|
||||
private async loadEpgForChannel(item: StalkerItvChannel) {
|
||||
const requestId = ++this.epgLoadRequestId;
|
||||
const normalizedChannelId = normalizeStalkerEntityId(item.id);
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id ?? null;
|
||||
const shouldEnsureBulk =
|
||||
!this.stalkerStore.bulkItvEpgLoaded() ||
|
||||
this.stalkerStore.bulkItvEpgPlaylistId() !== playlistId ||
|
||||
this.stalkerStore.bulkItvEpgPeriodHours() !== 168;
|
||||
|
||||
this.fallbackEpgPrograms.set([]);
|
||||
this.isLoadingFallbackEpg.set(false);
|
||||
|
||||
try {
|
||||
if (shouldEnsureBulk) {
|
||||
await this.stalkerStore.ensureBulkItvEpg(168);
|
||||
if (
|
||||
!this.isCurrentEpgRequest(
|
||||
requestId,
|
||||
normalizedChannelId
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.stalkerStore.selectedItvEpgPrograms().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingFallbackEpg.set(true);
|
||||
const fallbackItems = await this.stalkerStore.fetchChannelEpg(
|
||||
item.id
|
||||
);
|
||||
if (
|
||||
!this.isCurrentEpgRequest(requestId, normalizedChannelId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fallbackEpgPrograms.set(
|
||||
fallbackItems.map((epgItem) =>
|
||||
this.toProgram(epgItem, normalizedChannelId)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to load Stalker live EPG', error);
|
||||
if (
|
||||
this.isCurrentEpgRequest(requestId, normalizedChannelId)
|
||||
) {
|
||||
this.fallbackEpgPrograms.set([]);
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
this.isCurrentEpgRequest(requestId, normalizedChannelId)
|
||||
) {
|
||||
this.isLoadingFallbackEpg.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearEpgPreviewMaps() {
|
||||
this.epgPreviewPrograms.clear();
|
||||
this.currentProgramsProgress.clear();
|
||||
}
|
||||
|
||||
private syncBulkEpgPreviews(channels: StalkerItvChannel[]): void {
|
||||
this.clearEpgPreviewMaps();
|
||||
|
||||
const bulkProgramsByChannel = this.stalkerStore.bulkItvEpgByChannel();
|
||||
if (
|
||||
channels.length === 0 ||
|
||||
Object.keys(bulkProgramsByChannel).length === 0
|
||||
) {
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const channel of channels) {
|
||||
const channelId = normalizeStalkerEntityId(channel.id);
|
||||
const currentProgram = this.findCurrentProgram(
|
||||
bulkProgramsByChannel[channelId] ?? []
|
||||
);
|
||||
|
||||
if (!currentProgram) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.epgPreviewPrograms.set(channelId, currentProgram);
|
||||
this.updateProgramProgress(channelId, currentProgram);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private updateProgramProgress(
|
||||
channelId: string | number,
|
||||
program: EpgProgram
|
||||
): void {
|
||||
const startMs = this.getProgramTimestampMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stopMs = this.getProgramTimestampMs(
|
||||
program.stop,
|
||||
program.stopTimestamp
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (
|
||||
Number.isFinite(startMs) &&
|
||||
Number.isFinite(stopMs) &&
|
||||
nowMs >= startMs &&
|
||||
nowMs <= stopMs &&
|
||||
stopMs > startMs
|
||||
) {
|
||||
this.currentProgramsProgress.set(
|
||||
channelId,
|
||||
((nowMs - startMs) / (stopMs - startMs)) * 100
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentProgramsProgress.delete(channelId);
|
||||
}
|
||||
|
||||
private setupScrollListener() {
|
||||
this.removeScrollListener();
|
||||
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const scrollThreshold = 150;
|
||||
|
||||
if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) {
|
||||
this.loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
this.scrollListener = () =>
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
private checkIfNeedsMoreContent() {
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = container;
|
||||
if (scrollHeight <= clientHeight) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private removeScrollListener() {
|
||||
if (this.scrollListener) {
|
||||
this.scrollListener();
|
||||
this.scrollListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private toProgram(
|
||||
item: EpgItem,
|
||||
channelId: string | number
|
||||
): EpgProgram {
|
||||
return {
|
||||
start: item.start,
|
||||
stop: item.stop || item.end,
|
||||
channel: String(channelId),
|
||||
title: item.title,
|
||||
desc: item.description || null,
|
||||
category: null,
|
||||
startTimestamp: this.toTimestamp(item.start_timestamp, item.start),
|
||||
stopTimestamp: this.toTimestamp(
|
||||
item.stop_timestamp,
|
||||
item.stop || item.end
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toTimestamp(
|
||||
rawTimestamp: string | number | null | undefined,
|
||||
rawDate: string
|
||||
): number | null {
|
||||
const timestamp = Number.parseInt(String(rawTimestamp ?? ''), 10);
|
||||
if (Number.isFinite(timestamp) && timestamp > 0) {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
const parsedDate = Date.parse(rawDate);
|
||||
return Number.isFinite(parsedDate)
|
||||
? Math.floor(parsedDate / 1000)
|
||||
: null;
|
||||
}
|
||||
|
||||
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
|
||||
const now = Date.now();
|
||||
return (
|
||||
programs.find((program) => {
|
||||
const start = this.getProgramTimestampMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stop = this.getProgramTimestampMs(
|
||||
program.stop,
|
||||
program.stopTimestamp
|
||||
);
|
||||
return start !== null && stop !== null && now >= start && now < stop;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private getProgramTimestampMs(
|
||||
rawDate: string,
|
||||
rawTimestamp?: number | null
|
||||
): number | null {
|
||||
if (Number.isFinite(rawTimestamp) && Number(rawTimestamp) > 0) {
|
||||
return Number(rawTimestamp) * 1000;
|
||||
}
|
||||
|
||||
const parsedDate = Date.parse(rawDate);
|
||||
return Number.isFinite(parsedDate) ? parsedDate : null;
|
||||
}
|
||||
|
||||
private isCurrentEpgRequest(
|
||||
requestId: number,
|
||||
normalizedChannelId: string
|
||||
): boolean {
|
||||
return (
|
||||
requestId === this.epgLoadRequestId &&
|
||||
this.selectedChannelId() === normalizedChannelId
|
||||
);
|
||||
}
|
||||
|
||||
private handleRemoteChannelChange(direction: 'up' | 'down'): void {
|
||||
const activeItem = this.stalkerStore.selectedItem();
|
||||
if (!activeItem?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = this.visibleChannels();
|
||||
const nextItem = getAdjacentChannelItem(
|
||||
channels,
|
||||
activeItem.id,
|
||||
direction,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
if (!nextItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.playChannel(nextItem);
|
||||
}
|
||||
|
||||
private handleRemoteControlCommand(command: {
|
||||
type:
|
||||
| 'channel-select-number'
|
||||
| 'volume-up'
|
||||
| 'volume-down'
|
||||
| 'volume-toggle-mute';
|
||||
number?: number;
|
||||
}): void {
|
||||
if (command.type !== 'channel-select-number' || !command.number) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = getChannelItemByNumber(
|
||||
this.visibleChannels(),
|
||||
command.number
|
||||
);
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.playChannel(channel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<app-search-layout
|
||||
[searchTerm]="searchTerm()"
|
||||
[resultsCount]="resultsCount"
|
||||
[isLoading]="searchResultsResource.isLoading()"
|
||||
[showResultsCount]="true"
|
||||
[showSearchInput]="!isWorkspaceLayout"
|
||||
[showDetails]="showingDetails"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
>
|
||||
@if (showingDetails) {
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Filters (single selection mode for stalker) -->
|
||||
<ng-container filters>
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[ngModel]="filters()[filter.key]"
|
||||
(ngModelChange)="updateFilter(filter.key, $event)"
|
||||
>
|
||||
{{ filter.translationKey | translate }}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Results -->
|
||||
<ng-container results>
|
||||
<div class="results-grid">
|
||||
@for (item of searchResultsResource.value(); track item.id) {
|
||||
<app-content-card
|
||||
[title]="item.o_name || item.name"
|
||||
[posterUrl]="item.screenshot_uri"
|
||||
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
</app-search-layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.details-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
resource,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Playlist,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
VodDetailsItem,
|
||||
} from 'shared-interfaces';
|
||||
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
|
||||
import { StalkerContentTypes } from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
queryParamSignal,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerSelectedVodItem,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
buildStalkerSelectedVodItem,
|
||||
clearStalkerDetailViewState,
|
||||
createStalkerInlineDetailState,
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
createStalkerDetailViewState,
|
||||
isSelectedStalkerVodFavorite,
|
||||
isStalkerSeriesFlag,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
interface StalkerFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
translationKey: string;
|
||||
}
|
||||
|
||||
interface StalkerSearchResponse {
|
||||
js?: {
|
||||
data?: StalkerVodSource[];
|
||||
};
|
||||
message?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-search',
|
||||
imports: [
|
||||
ContentCardComponent,
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
SearchLayoutComponent,
|
||||
StalkerInlineDetailComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './stalker-search.component.html',
|
||||
styleUrl: './stalker-search.component.scss',
|
||||
})
|
||||
export class StalkerSearchComponent {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly logger = createLogger('StalkerSearch');
|
||||
|
||||
readonly filters = signal({
|
||||
series: false,
|
||||
vod: true,
|
||||
});
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
|
||||
|
||||
readonly filterConfig: StalkerFilter[] = [
|
||||
{
|
||||
key: 'vod',
|
||||
label: 'Movies',
|
||||
translationKey: 'PORTALS.SIDEBAR.MOVIES',
|
||||
},
|
||||
{
|
||||
key: 'series',
|
||||
label: 'Series',
|
||||
translationKey: 'PORTALS.SIDEBAR.SERIES',
|
||||
},
|
||||
];
|
||||
|
||||
readonly searchTerm = signal('');
|
||||
readonly routeSearchTerm = queryParamSignal(
|
||||
this.activatedRoute,
|
||||
'q',
|
||||
(value) => (value ?? '').trim()
|
||||
);
|
||||
|
||||
private readonly currentPlaylist = computed(() => {
|
||||
const playlist = this.playlistContext.activePlaylist();
|
||||
return playlist?.macAddress ? playlist : null;
|
||||
});
|
||||
|
||||
readonly selectedFilterType = signal('vod');
|
||||
private readonly favoritesRefresh = createRefreshTrigger();
|
||||
|
||||
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(),
|
||||
search: this.searchTerm(),
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
if (params.search.length < 3) {
|
||||
return [];
|
||||
}
|
||||
const playlist = this.currentPlaylist();
|
||||
if (!playlist) return [];
|
||||
const { portalUrl, macAddress } = playlist;
|
||||
|
||||
let token: string | undefined;
|
||||
let serialNumber: string | undefined;
|
||||
if ((playlist as Playlist).isFullStalkerPortal) {
|
||||
try {
|
||||
const result = await this.stalkerSession.ensureToken(
|
||||
playlist as Playlist
|
||||
);
|
||||
token = result.token ?? undefined;
|
||||
serialNumber = (playlist as Playlist).stalkerSerialNumber;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get stalker token', error);
|
||||
}
|
||||
}
|
||||
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerSearchResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params: {
|
||||
action: StalkerContentTypes[params.contentType]
|
||||
.getContentAction,
|
||||
type: params.contentType,
|
||||
search: params.search,
|
||||
max_page_items: 100,
|
||||
},
|
||||
token,
|
||||
serialNumber,
|
||||
}
|
||||
);
|
||||
if (response) {
|
||||
const items = response.js?.data || [];
|
||||
return items.map((item: StalkerVodSource) =>
|
||||
this.processItemUrls(item, portalUrl)
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Error: ${response.message} (Status: ${response.status})`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
readonly isSelectedVodFavorite = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const routeTerm = this.routeSearchTerm();
|
||||
if (routeTerm !== this.searchTerm()) {
|
||||
this.searchTerm.set(routeTerm);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
// Re-evaluate favorite state whenever favorites resource changes.
|
||||
this.portalFavorites.value();
|
||||
this.syncSelectedVodFavorite();
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if showing item details */
|
||||
get showingDetails(): boolean {
|
||||
return this.inlineDetail().categoryId !== null;
|
||||
}
|
||||
|
||||
/** Get results count for layout */
|
||||
get resultsCount(): number {
|
||||
return this.searchResultsResource.value()?.length ?? 0;
|
||||
}
|
||||
|
||||
createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) {
|
||||
this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
updateSearchTerm(term: string) {
|
||||
this.searchTerm.set(term);
|
||||
}
|
||||
|
||||
updateFilter(key: string, value: boolean) {
|
||||
if (value) {
|
||||
// Single selection mode - set clicked filter, disable others
|
||||
this.selectedFilterType.set(key);
|
||||
this.filters.update((f) => {
|
||||
const newFilters: Record<string, boolean> = {};
|
||||
Object.keys(f).forEach((k) => {
|
||||
newFilters[k] = k === key;
|
||||
});
|
||||
return newFilters as typeof f;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(item: StalkerVodSource) {
|
||||
const hasEmbeddedSeries = item.series?.length > 0;
|
||||
const needsSeriesFetch =
|
||||
this.selectedFilterType() === 'vod' &&
|
||||
!hasEmbeddedSeries &&
|
||||
isStalkerSeriesFlag(item.is_series);
|
||||
|
||||
this.itemDetails = buildStalkerSelectedVodItem(item, needsSeriesFetch);
|
||||
|
||||
this.stalkerStore.setSelectedItem(this.itemDetails);
|
||||
|
||||
switch (this.selectedFilterType()) {
|
||||
case 'vod':
|
||||
this.stalkerStore.setSelectedContentType('vod');
|
||||
if (!hasEmbeddedSeries && !needsSeriesFetch) {
|
||||
const detailViewState = createStalkerDetailViewState(
|
||||
this.itemDetails,
|
||||
this.currentPlaylist()?._id ?? ''
|
||||
);
|
||||
this.itemDetails = detailViewState.itemDetails;
|
||||
this.vodDetailsItem = detailViewState.vodDetailsItem;
|
||||
this.syncSelectedVodFavorite();
|
||||
} else {
|
||||
const cleared = clearStalkerDetailViewState();
|
||||
this.vodDetailsItem = cleared.vodDetailsItem;
|
||||
this.isSelectedVodFavorite.set(false);
|
||||
}
|
||||
break;
|
||||
case 'series':
|
||||
this.stalkerStore.setSelectedContentType('series');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
this.createLinkToPlayVodItv(
|
||||
item.cmd,
|
||||
item.data.info?.name,
|
||||
item.data.info?.movie_image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onVodFavoriteToggled(event: {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}): void {
|
||||
toggleStalkerVodFavorite(event, {
|
||||
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
|
||||
removeFromFavorites: (favoriteId, onDone) =>
|
||||
this.removeFromFavorites(favoriteId, onDone),
|
||||
onComplete: () => {
|
||||
this.favoritesRefresh.refresh();
|
||||
this.syncSelectedVodFavorite();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onVodBack(): void {
|
||||
const cleared = clearStalkerDetailViewState();
|
||||
this.itemDetails = cleared.itemDetails;
|
||||
this.vodDetailsItem = cleared.vodDetailsItem;
|
||||
this.isSelectedVodFavorite.set(false);
|
||||
}
|
||||
|
||||
removeFromFavorites(favoriteId: string, onDone?: () => void) {
|
||||
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
|
||||
}
|
||||
|
||||
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
|
||||
this.stalkerStore.addToFavorites(item, onDone);
|
||||
}
|
||||
|
||||
private syncSelectedVodFavorite(): void {
|
||||
const item = this.vodDetailsItem;
|
||||
this.isSelectedVodFavorite.set(
|
||||
isSelectedStalkerVodFavorite(
|
||||
item,
|
||||
this.portalFavorites.value() ?? []
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
inlineDetail() {
|
||||
return createStalkerInlineDetailState(
|
||||
this.itemDetails,
|
||||
this.vodDetailsItem,
|
||||
this.selectedFilterType() === 'series' ? 'series' : 'vod'
|
||||
);
|
||||
}
|
||||
|
||||
private processItemUrls(
|
||||
item: StalkerVodSource,
|
||||
portalUrl: string
|
||||
): StalkerVodSource {
|
||||
const processed = { ...item };
|
||||
|
||||
if (processed.screenshot_uri) {
|
||||
processed.screenshot_uri = this.makeAbsoluteUrl(
|
||||
portalUrl,
|
||||
processed.screenshot_uri
|
||||
);
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
|
||||
if (!relativePath) return '';
|
||||
if (
|
||||
relativePath.startsWith('http://') ||
|
||||
relativePath.startsWith('https://')
|
||||
) {
|
||||
return relativePath;
|
||||
}
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
const path = relativePath.startsWith('/')
|
||||
? relativePath
|
||||
: `/${relativePath}`;
|
||||
return `${url.origin}${path}`;
|
||||
} catch {
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
@if (displayItem(); as serial) {
|
||||
<app-content-hero
|
||||
[title]="serial.info.name"
|
||||
[description]="serial.info.description"
|
||||
[posterUrl]="serial.info.movie_image"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<!-- Tags: Year, Genre, Rating -->
|
||||
<ng-container hero-tags>
|
||||
@if (serial.info.releasedate) {
|
||||
<span class="details__tag">{{
|
||||
serial.info.releasedate
|
||||
}}</span>
|
||||
}
|
||||
@if (serial.info.genre) {
|
||||
<span class="details__tag">{{
|
||||
serial.info.genre
|
||||
}}</span>
|
||||
}
|
||||
@if (serial.info.rating_imdb) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ serial.info.rating_imdb }}
|
||||
</span>
|
||||
}
|
||||
@if (serial.info.rating_kinopoisk) {
|
||||
<span class="details__tag">
|
||||
KP {{ serial.info.rating_kinopoisk }}
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
@if (serial.info.actors) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.ACTORS' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ serial.info.actors }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (serial.info.director) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ serial.info.director }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
<app-favorites-button
|
||||
[itemId]="serial.id"
|
||||
[item]="serial"
|
||||
/>
|
||||
</ng-container>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
[playback]="playback"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Seasons & Episodes -->
|
||||
@if (mappedSeasons()) {
|
||||
<app-season-container
|
||||
[seasons]="mappedSeasons()"
|
||||
[seriesId]="toSeriesId(serial.id)"
|
||||
[playlistId]="stalkerStore.currentPlaylist()?._id ?? ''"
|
||||
[seriesTitle]="serial.info?.name"
|
||||
[playbackPositions]="episodePlaybackPositions()"
|
||||
[openingEpisodeId]="openingEpisodeId()"
|
||||
[activeEpisodeId]="activeEpisodeId()"
|
||||
[isLoading]="
|
||||
isVodSeriesSeasonsLoading() ||
|
||||
(seasonContainer?.selectedSeason &&
|
||||
isCurrentSeasonLoading(seasonContainer.selectedSeason))
|
||||
"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
(episodeDownloadRequested)="downloadEpisode($event)"
|
||||
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
|
||||
#seasonContainer
|
||||
/>
|
||||
}
|
||||
</app-content-hero>
|
||||
}
|
||||
+685
@@ -0,0 +1,685 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
|
||||
import {
|
||||
ContentHeroComponent,
|
||||
SeasonContainerComponent,
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
} from 'components';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
XtreamSerieEpisode,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
createLogger,
|
||||
getStalkerReturnToState,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
getVodSeriesSeasonKey,
|
||||
isVodSeriesItem,
|
||||
mapRegularSeriesEpisodes,
|
||||
mapRegularSeriesSeasons,
|
||||
mapVodSeriesEpisodes,
|
||||
mapVodSeriesSeasonsToVm,
|
||||
StalkerMappedEpisode,
|
||||
StalkerSeriesSeasonVm,
|
||||
VodSeriesSeasonVm,
|
||||
normalizeStalkerVodDetailsItem,
|
||||
StalkerSelectedVodItem,
|
||||
StalkerStore,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { DownloadsService } from 'services';
|
||||
|
||||
/**
|
||||
* Component for displaying series/episodes for Stalker portal content.
|
||||
* Supports three modes:
|
||||
* 1. Regular series (type=series): Fetches seasons from API via serialSeasonsResource
|
||||
* 2. VOD with embedded series (vclub): Uses the series array from the vodWithSeries input
|
||||
* 3. VOD series (Ministra is_series=1): Fetches seasons/episodes using movie_id and season_id
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stalker-series-view',
|
||||
templateUrl: './stalker-series-view.component.html',
|
||||
styleUrls: ['../styles/detail-view.scss'],
|
||||
imports: [
|
||||
FavoritesButtonComponent,
|
||||
ContentHeroComponent,
|
||||
PortalInlinePlayerComponent,
|
||||
TranslatePipe,
|
||||
SeasonContainerComponent,
|
||||
],
|
||||
})
|
||||
export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly router = inject(Router);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
readonly backClicked = output<void>();
|
||||
private readonly logger = createLogger('StalkerSeriesView');
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly episodePlaybackPositions = signal<
|
||||
Map<number, PlaybackPositionData>
|
||||
>(new Map());
|
||||
private lastSaveTime = 0;
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
readonly activeEpisodeId = signal<number | null>(null);
|
||||
|
||||
/**
|
||||
* Optional input for VOD items with embedded series array (vclub mode)
|
||||
* When provided, uses this instead of fetching seasons from API
|
||||
*/
|
||||
readonly vodWithSeries = input<StalkerVodSource | null>(null);
|
||||
|
||||
readonly selectedItem = this.stalkerStore.selectedItem;
|
||||
|
||||
/**
|
||||
* Track VOD series seasons with their loaded episodes
|
||||
*/
|
||||
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(() => {
|
||||
return (
|
||||
this.stalkerStore.selectedContentType() === 'vod' &&
|
||||
isVodSeriesItem(this.displayItem())
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Loading state for VOD series seasons
|
||||
*/
|
||||
readonly isVodSeriesSeasonsLoading =
|
||||
this.stalkerStore.isVodSeriesSeasonsLoading;
|
||||
|
||||
/**
|
||||
* Loading state for regular series seasons
|
||||
*/
|
||||
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
|
||||
|
||||
constructor() {
|
||||
// Effect to load VOD series seasons when a VOD series item is selected
|
||||
effect(() => {
|
||||
if (this.isVodSeries()) {
|
||||
// Get seasons from the resource
|
||||
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
|
||||
this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons));
|
||||
} else {
|
||||
this.vodSeriesSeasons.set([]);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to load playback positions for Stalker series
|
||||
effect(() => {
|
||||
const item = this.displayItem();
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
if (item && playlist?._id) {
|
||||
const normalizedSeriesId = this.toSeriesId(item.id);
|
||||
this.logger.debug(
|
||||
'Loading positions for series',
|
||||
{
|
||||
id: item.id,
|
||||
seriesId: normalizedSeriesId,
|
||||
isSeries: item.is_series,
|
||||
}
|
||||
);
|
||||
if (!isNaN(normalizedSeriesId)) {
|
||||
void this.loadSeriesPositions(
|
||||
playlist._id,
|
||||
normalizedSeriesId
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.episodePlaybackPositions.set(new Map());
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const session = this.externalPlayback.activeSession();
|
||||
const item = this.displayItem();
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
const seriesId = item ? this.toSeriesId(item.id) : 0;
|
||||
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
!playlistId ||
|
||||
!seriesId ||
|
||||
session.contentInfo.contentType !== 'episode' ||
|
||||
session.contentInfo.playlistId !== playlistId ||
|
||||
session.contentInfo.seriesXtreamId !== seriesId
|
||||
) {
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === 'launching') {
|
||||
this.openingEpisodeId.set(session.contentInfo.contentXtreamId);
|
||||
this.activeEpisodeId.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === 'opened' || session.status === 'playing') {
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openingEpisodeId.set(null);
|
||||
this.activeEpisodeId.set(null);
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
this.unsubscribePositionUpdates =
|
||||
window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const playlistId =
|
||||
this.stalkerStore.currentPlaylist()?._id;
|
||||
const item = this.displayItem();
|
||||
const seriesId = item ? this.toSeriesId(item.id) : 0;
|
||||
|
||||
if (
|
||||
data.contentType !== 'episode' ||
|
||||
data.playlistId !== playlistId ||
|
||||
data.seriesXtreamId !== seriesId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateEpisodePlaybackPosition(data);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<StalkerSeriesSeasonVm[]>(() =>
|
||||
mapRegularSeriesSeasons(
|
||||
this.vodWithSeries(),
|
||||
this.stalkerStore.getSerialSeasonsResource()
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the item to display details for (either vodWithSeries or selectedItem from store)
|
||||
*/
|
||||
readonly displayItem = computed<StalkerSelectedVodItem | null>(() => {
|
||||
const item = this.vodWithSeries() || this.selectedItem();
|
||||
return item ? normalizeStalkerVodDetailsItem(item) : null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 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[]>>(
|
||||
() => {
|
||||
if (this.isVodSeries()) {
|
||||
return mapVodSeriesEpisodes(
|
||||
this.vodSeriesSeasons(),
|
||||
this.displayItem()?.info?.movie_image
|
||||
);
|
||||
}
|
||||
|
||||
return mapRegularSeriesEpisodes(
|
||||
this.regularSeasons(),
|
||||
this.displayItem()?.info?.movie_image
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles season selection from the container.
|
||||
* For VOD Series, triggers lazy loading of episodes.
|
||||
*/
|
||||
onSeasonSelected(seasonKey: string) {
|
||||
if (!this.isVodSeries()) return;
|
||||
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
const season = seasons.find(
|
||||
(s) => getVodSeriesSeasonKey(s) === seasonKey
|
||||
);
|
||||
|
||||
if (season && season.episodes.length === 0) {
|
||||
this.loadEpisodesForSeason(season);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads episodes for a specific VOD season
|
||||
*/
|
||||
async loadEpisodesForSeason(season: VodSeriesSeasonVm) {
|
||||
// Set loading state in local signal
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
const index = seasons.findIndex((s) => s.id === season.id);
|
||||
if (index === -1) return;
|
||||
|
||||
const updatedSeasons = [...seasons];
|
||||
updatedSeasons[index] = { ...updatedSeasons[index], isLoading: true };
|
||||
this.vodSeriesSeasons.set(updatedSeasons);
|
||||
|
||||
try {
|
||||
const episodes = await this.stalkerStore.fetchVodSeriesEpisodes(
|
||||
season.video_id,
|
||||
season.id
|
||||
);
|
||||
|
||||
// Update with loaded episodes
|
||||
const newSeasons = [...this.vodSeriesSeasons()];
|
||||
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
|
||||
if (newIndex !== -1) {
|
||||
newSeasons[newIndex] = {
|
||||
...newSeasons[newIndex],
|
||||
episodes: episodes,
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
} catch (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) {
|
||||
newSeasons[newIndex] = {
|
||||
...newSeasons[newIndex],
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current selected season is loading
|
||||
*/
|
||||
isCurrentSeasonLoading(seasonKey: string): boolean {
|
||||
if (!this.isVodSeries()) return false;
|
||||
const season = this.vodSeriesSeasons().find(
|
||||
(s) => getVodSeriesSeasonKey(s) === seasonKey
|
||||
);
|
||||
return season?.isLoading ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles episode click from the container
|
||||
*/
|
||||
onEpisodeClicked(episode: XtreamSerieEpisode) {
|
||||
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: mappedEpisode.originalId, // For constructing playback URL
|
||||
trackingId: episode.id, // The generated unique ID for position tracking
|
||||
name: episode.title,
|
||||
series_number: episode.episode_num,
|
||||
});
|
||||
} else {
|
||||
// Regular series or vclub mode (VOD with embedded series array)
|
||||
// Use originalCmd for playback, episode.id for tracking
|
||||
const trackingId = Number(episode.id);
|
||||
this.playEpisodeClicked(
|
||||
episode.episode_num,
|
||||
mappedEpisode.originalCmd,
|
||||
trackingId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play episode - handles regular series and vclub mode
|
||||
*/
|
||||
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
|
||||
const item = this.displayItem();
|
||||
if (!item) return;
|
||||
this.logger.debug('playEpisodeClicked', {
|
||||
episodeNum,
|
||||
cmd,
|
||||
trackingId,
|
||||
seriesId: item.id,
|
||||
});
|
||||
|
||||
const position = trackingId
|
||||
? this.episodePlaybackPositions().get(trackingId)
|
||||
: undefined;
|
||||
const startTime = position?.positionSeconds;
|
||||
void this.startPlayback(
|
||||
cmd,
|
||||
item.info.name,
|
||||
item.info.movie_image,
|
||||
episodeNum,
|
||||
trackingId,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play VOD series episode
|
||||
*/
|
||||
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 episodeName = episode.name || `Episode ${episode.series_number}`;
|
||||
// Use trackingId (generated unique ID) for playback position tracking
|
||||
const trackingId = Number(episode.trackingId);
|
||||
|
||||
this.logger.debug('playVodSeriesEpisode', {
|
||||
originalId: episode.originalId,
|
||||
trackingId,
|
||||
seriesId: item.id,
|
||||
episodeName,
|
||||
});
|
||||
|
||||
const position = this.episodePlaybackPositions().get(trackingId);
|
||||
const startTime = position?.positionSeconds;
|
||||
void this.startPlayback(
|
||||
cmd,
|
||||
`${item.info.name} - ${episodeName}`,
|
||||
item.info.movie_image,
|
||||
episode.series_number,
|
||||
trackingId,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
const returnTo = getStalkerReturnToState(window.history.state);
|
||||
this.closeInlinePlayer();
|
||||
this.backClicked.emit();
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
|
||||
if (returnTo) {
|
||||
void this.router.navigateByUrl(returnTo);
|
||||
}
|
||||
}
|
||||
|
||||
toSeriesId(id: string | number): number {
|
||||
const raw = String(id ?? '').trim();
|
||||
if (!raw) return 0;
|
||||
const primary = raw.includes(':') ? raw.split(':')[0] : raw;
|
||||
const parsed = Number(primary);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.inlinePlayback.set(null);
|
||||
this.lastSaveTime = 0;
|
||||
}
|
||||
|
||||
handleInlineTimeUpdate(event: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}): void {
|
||||
const playback = this.inlinePlayback();
|
||||
if (!playback?.contentInfo) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastSaveTime <= 15000) return;
|
||||
|
||||
this.lastSaveTime = now;
|
||||
const position: PlaybackPositionData = {
|
||||
...playback.contentInfo,
|
||||
positionSeconds: Math.floor(event.currentTime),
|
||||
durationSeconds: Math.floor(event.duration),
|
||||
};
|
||||
void this.playbackPositions.savePlaybackPosition(
|
||||
playback.contentInfo.playlistId,
|
||||
position
|
||||
);
|
||||
this.updateEpisodePlaybackPosition(position);
|
||||
}
|
||||
|
||||
showCopyNotification(): void {
|
||||
this.snackBar.open(
|
||||
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
|
||||
null,
|
||||
{
|
||||
duration: 2000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async startPlayback(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
episodeNum?: number,
|
||||
episodeId?: number,
|
||||
startTime?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const playback = await this.stalkerStore.resolveVodPlayback(
|
||||
cmd,
|
||||
title,
|
||||
thumbnail,
|
||||
episodeNum,
|
||||
episodeId,
|
||||
startTime
|
||||
);
|
||||
|
||||
this.lastSaveTime = 0;
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
void this.portalPlayer.openResolvedPlayback(playback, true);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start inline series playback', error);
|
||||
const errorMessage =
|
||||
error instanceof Error &&
|
||||
error.message === 'nothing_to_play'
|
||||
? this.translateService.instant(
|
||||
'PORTALS.CONTENT_NOT_AVAILABLE'
|
||||
)
|
||||
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
|
||||
this.snackBar.open(errorMessage, null, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribePositionUpdates?.();
|
||||
}
|
||||
|
||||
async handlePlaybackToggleRequested(
|
||||
request: SeasonContainerPlaybackToggleRequest
|
||||
): Promise<void> {
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.nextPosition) {
|
||||
await this.playbackPositions.savePlaybackPosition(
|
||||
playlistId,
|
||||
request.nextPosition
|
||||
);
|
||||
this.updateEpisodePlaybackPosition(request.nextPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.playbackPositions.clearPlaybackPosition(
|
||||
playlistId,
|
||||
request.contentXtreamId,
|
||||
'episode'
|
||||
);
|
||||
this.removeEpisodePlaybackPosition(request.contentXtreamId);
|
||||
}
|
||||
|
||||
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
const item = this.displayItem();
|
||||
|
||||
if (!playlist || !playlist.portalUrl || !playlist.macAddress || !item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customSid = (episode as { custom_sid?: string }).custom_sid;
|
||||
const cmd =
|
||||
customSid === 'vod-series'
|
||||
? `/media/file_${(episode as { originalId?: string }).originalId}.mpg`
|
||||
: ((episode as { originalCmd?: string }).originalCmd ?? '');
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = await this.stalkerStore.fetchLinkToPlay(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
cmd,
|
||||
episode.episode_num
|
||||
);
|
||||
if (!url) {
|
||||
this.logger.error('Failed to resolve Stalker stream URL');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error resolving Stalker stream URL', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const episodeInfo = this.getEpisodeInfo(episode);
|
||||
const posterUrl = episodeInfo?.movie_image;
|
||||
const seasonNum = Number(episode.season || 1);
|
||||
const episodeNum = episode.episode_num || 1;
|
||||
const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series';
|
||||
const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
|
||||
|
||||
await this.downloadsService.startDownload({
|
||||
playlistId: playlist._id,
|
||||
xtreamId: this.getEpisodeDownloadId(episode),
|
||||
contentType: 'episode',
|
||||
title: episodeTitle,
|
||||
url,
|
||||
posterUrl,
|
||||
seriesXtreamId: this.toSeriesId(item.id),
|
||||
seasonNumber: seasonNum,
|
||||
episodeNumber: episodeNum,
|
||||
headers: {
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
},
|
||||
playlistName: playlist.title || 'Stalker Portal',
|
||||
playlistType: 'stalker',
|
||||
portalUrl: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
});
|
||||
}
|
||||
|
||||
private async loadSeriesPositions(
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
): Promise<void> {
|
||||
const positions = await this.playbackPositions.getSeriesPlaybackPositions(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
);
|
||||
const positionsMap = new Map<number, PlaybackPositionData>();
|
||||
positions.forEach((position) => {
|
||||
positionsMap.set(position.contentXtreamId, position);
|
||||
});
|
||||
this.episodePlaybackPositions.set(positionsMap);
|
||||
}
|
||||
|
||||
private updateEpisodePlaybackPosition(position: PlaybackPositionData): void {
|
||||
const updated = new Map(this.episodePlaybackPositions());
|
||||
updated.set(position.contentXtreamId, position);
|
||||
this.episodePlaybackPositions.set(updated);
|
||||
}
|
||||
|
||||
private removeEpisodePlaybackPosition(contentXtreamId: number): void {
|
||||
const updated = new Map(this.episodePlaybackPositions());
|
||||
updated.delete(contentXtreamId);
|
||||
this.episodePlaybackPositions.set(updated);
|
||||
}
|
||||
|
||||
private getEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): { movie_image?: string } | undefined {
|
||||
if (!episode.info || Array.isArray(episode.info)) {
|
||||
return undefined;
|
||||
}
|
||||
return episode.info as { movie_image?: string };
|
||||
}
|
||||
|
||||
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
|
||||
const customSid = (episode as { custom_sid?: string }).custom_sid;
|
||||
|
||||
if (customSid === 'regular-series') {
|
||||
const cmd = (episode as { originalCmd?: string }).originalCmd;
|
||||
if (cmd) {
|
||||
const match = cmd.match(/file_(\d+)/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
return this.hashString(cmd);
|
||||
}
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
if (customSid === 'vod-series') {
|
||||
const originalId = (episode as { originalId?: string | number })
|
||||
.originalId;
|
||||
const numericId = Number(originalId);
|
||||
return Number.isNaN(numericId)
|
||||
? this.hashString(String(originalId))
|
||||
: numericId;
|
||||
}
|
||||
|
||||
const numericId = Number(episode.id);
|
||||
return Number.isNaN(numericId)
|
||||
? this.hashString(String(episode.id))
|
||||
: numericId;
|
||||
}
|
||||
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < str.length; index++) {
|
||||
const char = str.charCodeAt(index);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash &= hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
|
||||
|
||||
const PLAYLIST_ID = 'stalker-1';
|
||||
const ACTIVE_PLAYLIST: PlaylistMeta = {
|
||||
_id: PLAYLIST_ID,
|
||||
filename: 'stalker.m3u',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
portalUrl: 'http://localhost/stalker_portal/server/load.php',
|
||||
title: 'Test Stalker',
|
||||
} as PlaylistMeta;
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function getStalkerSectionFromUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^\/workspace\/stalker\/[^/]+\/([^/?]+)(?:\/|$)/
|
||||
);
|
||||
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
describe('StalkerWorkspaceRouteSession', () => {
|
||||
const routerEvents = new Subject<NavigationEnd>();
|
||||
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
|
||||
const selectedContentType = signal<'vod' | 'itv' | 'series'>('vod');
|
||||
|
||||
const playlistContext = {
|
||||
activePlaylist,
|
||||
syncFromUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const stalkerStore = {
|
||||
resetCategories: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
clearSelectedItem: jest.fn(),
|
||||
setCurrentPlaylist: jest.fn().mockResolvedValue(undefined),
|
||||
setSelectedContentType: jest.fn((type: 'vod' | 'itv' | 'series') => {
|
||||
selectedContentType.set(type);
|
||||
}),
|
||||
setSearchPhrase: jest.fn(),
|
||||
};
|
||||
|
||||
const playlistsService = {
|
||||
getPlaylistById: jest.fn(() => of(ACTIVE_PLAYLIST)),
|
||||
};
|
||||
|
||||
const router = {
|
||||
url: `/workspace/stalker/${PLAYLIST_ID}/vod`,
|
||||
events: routerEvents.asObservable(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
|
||||
activePlaylist.set(ACTIVE_PLAYLIST);
|
||||
selectedContentType.set('vod');
|
||||
|
||||
playlistContext.syncFromUrl.mockImplementation((url: string) => ({
|
||||
inWorkspace: true,
|
||||
provider: 'stalker',
|
||||
playlistId: PLAYLIST_ID,
|
||||
section: getStalkerSectionFromUrl(url) as
|
||||
| 'favorites'
|
||||
| 'itv'
|
||||
| 'recent'
|
||||
| 'search'
|
||||
| 'series'
|
||||
| 'vod'
|
||||
| null,
|
||||
}));
|
||||
|
||||
stalkerStore.resetCategories.mockClear();
|
||||
stalkerStore.setSelectedCategory.mockClear();
|
||||
stalkerStore.clearSelectedItem.mockClear();
|
||||
stalkerStore.setCurrentPlaylist.mockClear();
|
||||
stalkerStore.setSelectedContentType.mockClear();
|
||||
stalkerStore.setSearchPhrase.mockClear();
|
||||
playlistsService.getPlaylistById.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerWorkspaceRouteSession,
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: playlistContext,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: stalkerStore,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the itv route selection after playlist bootstrap', async () => {
|
||||
router.url = `/workspace/stalker/${PLAYLIST_ID}/itv`;
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(stalkerStore.resetCategories).toHaveBeenCalled();
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
|
||||
ACTIVE_PLAYLIST
|
||||
);
|
||||
expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith('itv');
|
||||
expect(selectedContentType()).toBe('itv');
|
||||
expect(
|
||||
stalkerStore.setSelectedContentType.mock.invocationCallOrder[0]
|
||||
).toBeGreaterThan(
|
||||
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
inject,
|
||||
Injectable,
|
||||
Provider,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from 'services';
|
||||
|
||||
type StalkerContentType = 'vod' | 'series' | 'itv';
|
||||
|
||||
@Injectable()
|
||||
export class StalkerWorkspaceRouteSession {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
|
||||
private currentPlaylistId: string | null = null;
|
||||
private readonly currentSection = signal<PortalRailSection | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(
|
||||
(event): event is NavigationEnd =>
|
||||
event instanceof NavigationEnd
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(() => {
|
||||
void this.syncRouteContext();
|
||||
});
|
||||
|
||||
void this.syncRouteContext();
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.stalkerStore.resetCategories();
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
});
|
||||
}
|
||||
|
||||
private async syncRouteContext(): Promise<void> {
|
||||
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
|
||||
const playlistId =
|
||||
routeContext.provider === 'stalker'
|
||||
? routeContext.playlistId
|
||||
: null;
|
||||
|
||||
if (playlistId && this.currentPlaylistId !== playlistId) {
|
||||
this.currentPlaylistId = playlistId;
|
||||
|
||||
this.stalkerStore.resetCategories();
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
|
||||
const playlist =
|
||||
this.playlistContext.activePlaylist() ??
|
||||
(await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistId)
|
||||
));
|
||||
await this.stalkerStore.setCurrentPlaylist(playlist);
|
||||
}
|
||||
|
||||
this.syncRouteState(routeContext.section);
|
||||
}
|
||||
|
||||
private syncRouteState(section: PortalRailSection | null): void {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSection = this.currentSection();
|
||||
|
||||
if (section !== previousSection) {
|
||||
this.currentSection.set(section);
|
||||
}
|
||||
|
||||
if (section === 'vod' || section === 'series' || section === 'itv') {
|
||||
this.stalkerStore.setSelectedContentType(
|
||||
section as StalkerContentType
|
||||
);
|
||||
}
|
||||
|
||||
if (section === 'itv' && previousSection !== 'itv') {
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
this.stalkerStore.setSearchPhrase('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function provideStalkerWorkspaceRouteSession(): Provider[] {
|
||||
return [
|
||||
StalkerWorkspaceRouteSession,
|
||||
{
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
inject(StalkerWorkspaceRouteSession);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
// ============================================================================
|
||||
// Netflix-style Detail View - Cinematic Streaming Design
|
||||
// ============================================================================
|
||||
|
||||
:host {
|
||||
--poster-width: 220px;
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--content-max-width: 1400px;
|
||||
|
||||
// Light theme colors (default - no .dark-theme class on body)
|
||||
--hero-gradient-start: rgba(255, 250, 245, 0);
|
||||
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
|
||||
--hero-gradient-end: rgba(255, 250, 245, 1);
|
||||
--surface-bg: #fffaf5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--accent-color: #b81d24;
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
|
||||
--play-idle-fg: #161310;
|
||||
--play-idle-border: rgba(88, 48, 18, 0.12);
|
||||
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
|
||||
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
|
||||
--play-launching-fg: #241103;
|
||||
--play-launching-border: rgba(142, 66, 0, 0.2);
|
||||
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
|
||||
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
|
||||
--play-stop-fg: #321012;
|
||||
--play-stop-border: rgba(162, 38, 45, 0.18);
|
||||
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
|
||||
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
|
||||
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
// Dark theme overrides - cinematic dark mode
|
||||
:host-context(.dark-theme) {
|
||||
--hero-gradient-start: rgba(20, 20, 20, 0);
|
||||
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
|
||||
--hero-gradient-end: rgba(20, 20, 20, 1);
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--accent-color: #e50914;
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
|
||||
--play-idle-fg: #15110d;
|
||||
--play-idle-border: rgba(255, 255, 255, 0.18);
|
||||
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
|
||||
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
|
||||
--play-launching-fg: #1f1105;
|
||||
--play-launching-border: rgba(255, 223, 136, 0.26);
|
||||
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
|
||||
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
|
||||
--play-stop-fg: #2d1012;
|
||||
--play-stop-border: rgba(255, 205, 205, 0.24);
|
||||
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
|
||||
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
|
||||
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Details Section - Content Elements
|
||||
// ============================================================================
|
||||
|
||||
.details {
|
||||
// Styles for projected content elements
|
||||
|
||||
&__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: var(--tag-bg);
|
||||
border: 1px solid var(--tag-border);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&--rating {
|
||||
color: #ffd700;
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
font-size: 0.875rem;
|
||||
|
||||
.label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin-bottom: 2px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Buttons - Play, Favorite
|
||||
// ============================================================================
|
||||
|
||||
.play-btn {
|
||||
--play-btn-bg: var(--play-idle-bg);
|
||||
--play-btn-fg: var(--play-idle-fg);
|
||||
--play-btn-border: var(--play-idle-border);
|
||||
--play-btn-shadow: var(--play-idle-shadow);
|
||||
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 26px;
|
||||
min-height: 54px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
background: var(--play-btn-bg);
|
||||
background-size: 140% 140%;
|
||||
background-position: 0% 50%;
|
||||
color: var(--play-btn-fg);
|
||||
border: 1px solid var(--play-btn-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-position 280ms ease,
|
||||
box-shadow 260ms ease,
|
||||
border-color 220ms ease,
|
||||
color 220ms ease,
|
||||
filter 220ms ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(115deg, rgba(255, 255, 255, 0.34), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 62%);
|
||||
opacity: 0.7;
|
||||
transition: opacity 220ms ease;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
background-position: 100% 50%;
|
||||
box-shadow:
|
||||
var(--play-btn-shadow),
|
||||
0 10px 20px rgba(255, 255, 255, 0.16) inset;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled)::before {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
filter: saturate(1.05);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
color 220ms ease,
|
||||
filter 220ms ease;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) .play-btn__icon {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&__icon-shell {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
background: var(--play-icon-shell-bg);
|
||||
border: 1px solid var(--play-icon-shell-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.38),
|
||||
0 6px 14px rgba(0, 0, 0, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
background-color 220ms ease,
|
||||
border-color 220ms ease,
|
||||
box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--state-launching,
|
||||
&--state-stop {
|
||||
color: var(--play-btn-fg);
|
||||
}
|
||||
|
||||
&--state-launching {
|
||||
--play-btn-bg: var(--play-launching-bg);
|
||||
--play-btn-fg: var(--play-launching-fg);
|
||||
--play-btn-border: var(--play-launching-border);
|
||||
--play-btn-shadow: var(--play-launching-shadow);
|
||||
animation: playButtonPulse 1.8s ease-in-out infinite;
|
||||
|
||||
.play-btn__icon-shell {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.42),
|
||||
0 10px 22px rgba(184, 89, 26, 0.2);
|
||||
}
|
||||
|
||||
.play-btn__icon {
|
||||
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&--state-stop {
|
||||
--play-btn-bg: var(--play-stop-bg);
|
||||
--play-btn-fg: var(--play-stop-fg);
|
||||
--play-btn-border: var(--play-stop-border);
|
||||
--play-btn-shadow: var(--play-stop-shadow);
|
||||
|
||||
.play-btn__icon-shell {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4),
|
||||
0 12px 24px rgba(145, 38, 53, 0.18);
|
||||
}
|
||||
|
||||
.play-btn__icon {
|
||||
animation: playIconStop 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playButtonPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow:
|
||||
var(--play-btn-shadow),
|
||||
0 0 0 6px rgba(255, 190, 92, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playIconLaunch {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(18deg) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playIconStop {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.play-btn,
|
||||
.play-btn::before,
|
||||
.play-btn mat-icon,
|
||||
.play-btn__icon-shell {
|
||||
animation: none !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-btn,
|
||||
.download-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: rgba(109, 109, 110, 0.7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
&--active {
|
||||
background: rgba(229, 9, 20, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 9, 20, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: rgba(33, 150, 243, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(33, 150, 243, 0.6);
|
||||
}
|
||||
|
||||
&--completed {
|
||||
background: rgba(76, 175, 80, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
&--downloading {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Sections - Below the hero fold
|
||||
// ============================================================================
|
||||
|
||||
.content-section {
|
||||
padding: 20px 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seasons & Episodes - Inside details section
|
||||
// ============================================================================
|
||||
|
||||
.seasons {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
|
||||
.season-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.episodes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 8px 0 16px;
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.episodes-loading,
|
||||
.seasons-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-episodes {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Seasons loading when inside details
|
||||
.details .seasons-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
// Season container when inside details section
|
||||
.details app-season-container {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YouTube Trailer Section - Inside details
|
||||
// ============================================================================
|
||||
|
||||
.youtube-trailer {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trailer-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy container support (for gradual migration)
|
||||
// ============================================================================
|
||||
|
||||
.container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.image {
|
||||
display: none; // Hidden - now using .poster in hero
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Responsive - Option A: Keep poster grid layout
|
||||
// ============================================================================
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:host {
|
||||
--poster-width: 120px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
// This class is in ContentHeroComponent now, but these rules apply to children?
|
||||
// No, .play-btn is a child of the PROJECTED content.
|
||||
// So we should target the buttons directly for mobile
|
||||
}
|
||||
|
||||
.play-btn,
|
||||
.favorite-btn,
|
||||
.download-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.youtube-trailer {
|
||||
margin-top: 16px;
|
||||
|
||||
.trailer-wrapper {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.seasons {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animations
|
||||
// ============================================================================
|
||||
|
||||
.youtube-trailer {
|
||||
animation: fadeInUp 0.5s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility - keep .label for backwards compatibility
|
||||
// ============================================================================
|
||||
|
||||
.label {
|
||||
font-weight: 400;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user