mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(xtream): enhance VOD details handling with fallback mechanisms and metadata validation
- Updated XtreamUrlService to handle missing stream IDs and extensions gracefully. - Modified XtreamSelectionItem interface to allow for empty info arrays and null values. - Introduced findVodCatalogItem function in XtreamStore for better catalog item retrieval. - Added unit tests for fallback view model generation in vod-details-fallback.util.spec.ts. - Created vod-details-fallback.util.ts to manage fallback view model construction and metadata checks. - Updated vod-details-route.component.html to display fallback information when metadata is unavailable. - Enhanced styling for fallback notes and status indicators in vod-details-route.component.scss. - Implemented comprehensive tests for VodDetailsRouteComponent to ensure proper rendering of fallback and detailed views. - Refactored xtream-vod-details.interface.ts to include new utility functions for metadata extraction.
This commit is contained in:
1 parent
7d4d9a280a
commit
4175fc57b1
11 files changed
+990
-98
No files matched your search
@@ -78,9 +78,11 @@ export class XtreamUrlService {
|
||||
vodItem: XtreamVodDetails
|
||||
): string {
|
||||
const vod = vodItem as XtreamVodStreamLike;
|
||||
const streamId =
|
||||
vod.movie_data.stream_id ?? vod.stream_id;
|
||||
const extension = vodItem.movie_data.container_extension;
|
||||
const streamId = vod.movie_data?.stream_id ?? vod.stream_id;
|
||||
const extension = vodItem.movie_data?.container_extension;
|
||||
if (!streamId || !extension) {
|
||||
return '';
|
||||
}
|
||||
return `${credentials.serverUrl}/movie/${credentials.username}/${credentials.password}/${streamId}.${extension}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,27 +68,30 @@ interface XtreamSelectionItem {
|
||||
readonly category_id?: string | number;
|
||||
readonly episodes?: unknown;
|
||||
readonly id?: string | number;
|
||||
readonly info?: {
|
||||
readonly actors?: string;
|
||||
readonly backdrop_path?: string[];
|
||||
readonly cast?: string;
|
||||
readonly country?: string;
|
||||
readonly cover?: string;
|
||||
readonly description?: string;
|
||||
readonly director?: string;
|
||||
readonly duration?: string;
|
||||
readonly episode_run_time?: number | string;
|
||||
readonly genre?: string;
|
||||
readonly movie_image?: string;
|
||||
readonly name?: string;
|
||||
readonly plot?: string;
|
||||
readonly rating?: number | string;
|
||||
readonly rating_imdb?: string;
|
||||
readonly rating_kinopoisk?: string;
|
||||
readonly releaseDate?: string;
|
||||
readonly releasedate?: string;
|
||||
readonly youtube_trailer?: string;
|
||||
};
|
||||
readonly info?:
|
||||
| {
|
||||
readonly actors?: string;
|
||||
readonly backdrop_path?: string[];
|
||||
readonly cast?: string;
|
||||
readonly country?: string;
|
||||
readonly cover?: string;
|
||||
readonly description?: string;
|
||||
readonly director?: string;
|
||||
readonly duration?: string;
|
||||
readonly episode_run_time?: number | string;
|
||||
readonly genre?: string;
|
||||
readonly movie_image?: string;
|
||||
readonly name?: string;
|
||||
readonly plot?: string;
|
||||
readonly rating?: number | string;
|
||||
readonly rating_imdb?: string;
|
||||
readonly rating_kinopoisk?: string;
|
||||
readonly releaseDate?: string;
|
||||
readonly releasedate?: string;
|
||||
readonly youtube_trailer?: string;
|
||||
}
|
||||
| []
|
||||
| null;
|
||||
readonly last_modified?: string;
|
||||
readonly movie_data?: {
|
||||
readonly name?: string;
|
||||
|
||||
@@ -74,6 +74,15 @@ export const XtreamStore = signalStore(
|
||||
const xtreamApiService = inject(XtreamApiService);
|
||||
const dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
const logger = createLogger('XtreamStore');
|
||||
const findVodCatalogItem = (vodId: string | number) =>
|
||||
store.vodStreams().find((item) => {
|
||||
const candidateId =
|
||||
item.xtream_id ??
|
||||
item.stream_id ??
|
||||
(item as { id?: string | number }).id;
|
||||
|
||||
return Number(candidateId) === Number(vodId);
|
||||
});
|
||||
const searchContent = (
|
||||
store as {
|
||||
searchContent: (
|
||||
@@ -163,10 +172,16 @@ export const XtreamStore = signalStore(
|
||||
params.vodId
|
||||
)
|
||||
.then((vodDetails: XtreamVodDetails) => {
|
||||
const catalogItem = findVodCatalogItem(params.vodId);
|
||||
|
||||
store.setSelectedCategory(params.categoryId);
|
||||
store.setSelectedItem({
|
||||
...catalogItem,
|
||||
...vodDetails,
|
||||
stream_id: params.vodId,
|
||||
xtream_id:
|
||||
catalogItem?.xtream_id ??
|
||||
Number(params.vodId),
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { XtreamVodDetails, XtreamVodStream } from 'shared-interfaces';
|
||||
import {
|
||||
buildXtreamVodFallbackViewModel,
|
||||
hasUsableXtreamVodMetadata,
|
||||
} from './vod-details-fallback.util';
|
||||
|
||||
describe('vod-details-fallback.util', () => {
|
||||
it('treats populated Xtream info as usable metadata', () => {
|
||||
const vodDetails: XtreamVodDetails = {
|
||||
info: {
|
||||
kinopoisk_url: '',
|
||||
tmdb_id: 1,
|
||||
name: 'City of McFarland',
|
||||
o_name: 'City of McFarland',
|
||||
cover_big: 'https://example.com/poster.jpg',
|
||||
movie_image: 'https://example.com/poster.jpg',
|
||||
releasedate: '2015-02-20',
|
||||
episode_run_time: 120,
|
||||
youtube_trailer: '',
|
||||
director: 'Niki Caro',
|
||||
actors: 'Kevin Costner',
|
||||
cast: 'Kevin Costner',
|
||||
description: 'A real description',
|
||||
plot: 'A real plot',
|
||||
age: '',
|
||||
mpaa_rating: '',
|
||||
rating_count_kinopoisk: 0,
|
||||
country: 'US',
|
||||
genre: 'Drama',
|
||||
backdrop_path: ['https://example.com/backdrop.jpg'],
|
||||
duration_secs: 7200,
|
||||
duration: '02:00:00',
|
||||
video: ['H.264'],
|
||||
audio: ['AAC'],
|
||||
bitrate: 5000,
|
||||
rating: 7.4,
|
||||
rating_imdb: '7.4',
|
||||
rating_kinopoisk: '7.4',
|
||||
},
|
||||
movie_data: {
|
||||
stream_id: 678140,
|
||||
name: 'City of McFarland (2015) DE',
|
||||
added: '1750671180',
|
||||
category_id: '235',
|
||||
container_extension: 'mkv',
|
||||
custom_sid: null,
|
||||
direct_source: '',
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasUsableXtreamVodMetadata(vodDetails)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds a curated fallback model when Xtream returns info as an empty array', () => {
|
||||
const catalogItem = {
|
||||
name: 'Die Kühe sind Los! (2004) DE',
|
||||
stream_id: 650020,
|
||||
stream_icon: 'https://example.com/cows.jpg',
|
||||
added: '1720000000',
|
||||
category_id: '235',
|
||||
container_extension: 'mp4',
|
||||
rating: 6.1,
|
||||
rating_imdb: '6.1',
|
||||
} satisfies Partial<XtreamVodStream>;
|
||||
|
||||
const fallback = buildXtreamVodFallbackViewModel({
|
||||
vodDetails: { info: [] },
|
||||
catalogItem,
|
||||
category: {
|
||||
category_id: '235',
|
||||
category_name: 'DE | DISNEY',
|
||||
},
|
||||
vodId: 650020,
|
||||
});
|
||||
|
||||
expect(hasUsableXtreamVodMetadata({ info: [] })).toBe(false);
|
||||
expect(fallback).toEqual({
|
||||
added: '2024-07-03',
|
||||
facts: [
|
||||
{
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.STREAM_ID',
|
||||
monospace: true,
|
||||
value: '650020',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.CATEGORY',
|
||||
value: 'DE | DISNEY',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.CONTAINER',
|
||||
monospace: true,
|
||||
value: 'MP4',
|
||||
},
|
||||
],
|
||||
format: 'MP4',
|
||||
posterUrl: 'https://example.com/cows.jpg',
|
||||
rating: '6.1',
|
||||
title: 'Die Kühe sind Los! (2004) DE',
|
||||
year: '2004',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the route id when movie_data and catalog context are missing', () => {
|
||||
const fallback = buildXtreamVodFallbackViewModel({
|
||||
vodDetails: {
|
||||
info: [],
|
||||
} as XtreamVodDetails,
|
||||
vodId: 650021,
|
||||
});
|
||||
|
||||
expect(fallback).toEqual({
|
||||
added: undefined,
|
||||
facts: [
|
||||
{
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.STREAM_ID',
|
||||
monospace: true,
|
||||
value: '650021',
|
||||
},
|
||||
],
|
||||
format: undefined,
|
||||
posterUrl: undefined,
|
||||
rating: undefined,
|
||||
title: 'VOD 650021',
|
||||
year: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses preserved catalog fields from the selected item when the details payload is sparse', () => {
|
||||
const fallback = buildXtreamVodFallbackViewModel({
|
||||
vodDetails: {
|
||||
info: [],
|
||||
xtream_id: 20000,
|
||||
title: "Ain't No Sunshine: venia turbo",
|
||||
poster_url: 'https://example.com/sunshine.jpg',
|
||||
rating_imdb: '7.7',
|
||||
added: '1743984000',
|
||||
container_extension: 'avi',
|
||||
} as XtreamVodDetails & {
|
||||
added?: string;
|
||||
container_extension?: string;
|
||||
poster_url?: string;
|
||||
rating_imdb?: string;
|
||||
title?: string;
|
||||
xtream_id?: number;
|
||||
},
|
||||
category: {
|
||||
category_name: 'Action',
|
||||
},
|
||||
vodId: 20000,
|
||||
});
|
||||
|
||||
expect(fallback).toEqual({
|
||||
added: '2025-04-07',
|
||||
facts: [
|
||||
{
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.STREAM_ID',
|
||||
monospace: true,
|
||||
value: '20000',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.CATEGORY',
|
||||
value: 'Action',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.CONTAINER',
|
||||
monospace: true,
|
||||
value: 'AVI',
|
||||
},
|
||||
],
|
||||
format: 'AVI',
|
||||
posterUrl: 'https://example.com/sunshine.jpg',
|
||||
rating: '7.7',
|
||||
title: "Ain't No Sunshine: venia turbo",
|
||||
year: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
XtreamVodDetails,
|
||||
XtreamVodInfo,
|
||||
XtreamVodStream,
|
||||
getXtreamVodInfo,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
type XtreamCategoryContext = {
|
||||
readonly id?: string | number;
|
||||
readonly name?: string;
|
||||
readonly category_id?: string | number;
|
||||
readonly category_name?: string;
|
||||
};
|
||||
|
||||
type XtreamVodCatalogContext = Partial<XtreamVodStream> & {
|
||||
readonly added_at?: string | number;
|
||||
readonly id?: string | number;
|
||||
readonly poster_url?: string;
|
||||
readonly title?: string;
|
||||
readonly xtream_id?: string | number;
|
||||
};
|
||||
|
||||
type XtreamVodDetailsContext = XtreamVodDetails &
|
||||
Partial<XtreamVodStream> & {
|
||||
readonly added_at?: string | number;
|
||||
readonly id?: string | number;
|
||||
readonly poster_url?: string;
|
||||
readonly title?: string;
|
||||
readonly xtream_id?: string | number;
|
||||
};
|
||||
|
||||
export interface XtreamVodFallbackFact {
|
||||
readonly labelKey: string;
|
||||
readonly monospace?: boolean;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export interface XtreamVodFallbackViewModel {
|
||||
readonly added?: string;
|
||||
readonly facts: XtreamVodFallbackFact[];
|
||||
readonly format?: string;
|
||||
readonly posterUrl?: string;
|
||||
readonly rating?: string;
|
||||
readonly title: string;
|
||||
readonly year?: string;
|
||||
}
|
||||
|
||||
export interface XtreamVodFallbackOptions {
|
||||
readonly category?: XtreamCategoryContext | null;
|
||||
readonly catalogItem?: XtreamVodCatalogContext | null;
|
||||
readonly vodDetails?: XtreamVodDetails | null;
|
||||
readonly vodId: number;
|
||||
}
|
||||
|
||||
export function hasUsableXtreamVodMetadata(
|
||||
vodDetails: XtreamVodDetails | null | undefined
|
||||
): vodDetails is XtreamVodDetails & { info: XtreamVodInfo } {
|
||||
const info = getXtreamVodInfo(vodDetails);
|
||||
if (!info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
info.description ||
|
||||
info.plot ||
|
||||
info.movie_image ||
|
||||
info.cover_big ||
|
||||
info.genre ||
|
||||
info.releasedate ||
|
||||
info.duration ||
|
||||
info.duration_secs ||
|
||||
info.country ||
|
||||
info.director ||
|
||||
info.actors ||
|
||||
info.cast ||
|
||||
info.youtube_trailer ||
|
||||
info.rating_imdb ||
|
||||
info.rating_kinopoisk ||
|
||||
info.backdrop_path?.length
|
||||
);
|
||||
}
|
||||
|
||||
export function buildXtreamVodFallbackViewModel(
|
||||
options: XtreamVodFallbackOptions
|
||||
): XtreamVodFallbackViewModel | null {
|
||||
const detailItem = options.vodDetails as XtreamVodDetailsContext | null;
|
||||
const info = getXtreamVodInfo(detailItem);
|
||||
const movieData = detailItem?.movie_data;
|
||||
const catalogItem = options.catalogItem;
|
||||
const categoryName =
|
||||
options.category?.name ?? options.category?.category_name;
|
||||
const streamId =
|
||||
movieData?.stream_id ??
|
||||
numericValue(detailItem?.xtream_id) ??
|
||||
numericValue(detailItem?.stream_id) ??
|
||||
numericValue(detailItem?.id) ??
|
||||
numericValue(catalogItem?.xtream_id) ??
|
||||
numericValue(catalogItem?.stream_id) ??
|
||||
numericValue(catalogItem?.id) ??
|
||||
options.vodId;
|
||||
const sourceTitle =
|
||||
sanitizeText(detailItem?.title) ??
|
||||
sanitizeText(detailItem?.name) ??
|
||||
sanitizeText(catalogItem?.title) ??
|
||||
sanitizeText(catalogItem?.name);
|
||||
const title =
|
||||
sanitizeText(movieData?.name) ??
|
||||
sourceTitle ??
|
||||
sanitizeText(info?.name) ??
|
||||
`VOD ${streamId}`;
|
||||
const posterUrl =
|
||||
sanitizeText(detailItem?.poster_url) ??
|
||||
sanitizeText(detailItem?.stream_icon) ??
|
||||
sanitizeText(catalogItem?.poster_url) ??
|
||||
sanitizeText(catalogItem?.stream_icon) ??
|
||||
sanitizeText(info?.movie_image) ??
|
||||
sanitizeText(info?.cover_big) ??
|
||||
undefined;
|
||||
const rating =
|
||||
sanitizeText(detailItem?.rating_imdb) ??
|
||||
formatRating(detailItem?.rating) ??
|
||||
sanitizeText(catalogItem?.rating_imdb) ??
|
||||
formatRating(catalogItem?.rating);
|
||||
const added = formatUnixDate(
|
||||
movieData?.added ??
|
||||
detailItem?.added_at ??
|
||||
detailItem?.added ??
|
||||
catalogItem?.added_at ??
|
||||
catalogItem?.added
|
||||
);
|
||||
const format = sanitizeText(
|
||||
movieData?.container_extension ??
|
||||
detailItem?.container_extension ??
|
||||
catalogItem?.container_extension
|
||||
)?.toUpperCase();
|
||||
const year = extractYear(info?.releasedate) ?? extractYear(title);
|
||||
|
||||
const facts: XtreamVodFallbackFact[] = [
|
||||
{
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.STREAM_ID',
|
||||
monospace: true,
|
||||
value: String(streamId),
|
||||
},
|
||||
];
|
||||
|
||||
if (categoryName) {
|
||||
facts.push({
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.CATEGORY',
|
||||
value: categoryName,
|
||||
});
|
||||
}
|
||||
|
||||
if (format) {
|
||||
facts.push({
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.CONTAINER',
|
||||
monospace: true,
|
||||
value: format,
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceTitle && sourceTitle !== title) {
|
||||
facts.push({
|
||||
labelKey: 'XTREAM.DETAIL_FALLBACK.SOURCE_TITLE',
|
||||
value: sourceTitle,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
added,
|
||||
facts,
|
||||
format,
|
||||
posterUrl,
|
||||
rating,
|
||||
title,
|
||||
year,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeText(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function numericValue(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatRating(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractYear(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const yearMatch = value.match(/\b(19|20)\d{2}\b/);
|
||||
return yearMatch?.[0];
|
||||
}
|
||||
|
||||
function formatUnixDate(value: unknown): string | undefined {
|
||||
const seconds = numericValue(value);
|
||||
if (!seconds) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Date(seconds * 1000).toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,55 @@
|
||||
@let item = selectedItem();
|
||||
@if (item && item.info) {
|
||||
@if (selectedVodInfo(); as info) {
|
||||
<app-content-hero
|
||||
[title]="item.info?.name || item?.movie_data?.name"
|
||||
[description]="item.info.description || item.info.plot"
|
||||
[posterUrl]="item.info.movie_image"
|
||||
[backdropUrl]="item.info?.backdrop_path?.[0]"
|
||||
[title]="info.name || item?.movie_data?.name"
|
||||
[description]="info.description || info.plot"
|
||||
[posterUrl]="info.movie_image || info.cover_big"
|
||||
[backdropUrl]="info.backdrop_path?.[0]"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<ng-container hero-tags>
|
||||
@if (item.info?.releasedate) {
|
||||
@if (info.releasedate) {
|
||||
<span class="details__tag">{{
|
||||
item.info.releasedate | slice: 0 : 4
|
||||
info.releasedate | slice: 0 : 4
|
||||
}}</span>
|
||||
}
|
||||
@if (item.info?.genre) {
|
||||
<span class="details__tag">{{ item.info.genre }}</span>
|
||||
@if (info.genre) {
|
||||
<span class="details__tag">{{ info.genre }}</span>
|
||||
}
|
||||
@if (item.info?.duration) {
|
||||
<span class="details__tag">{{ item.info.duration }}</span>
|
||||
@if (info.duration) {
|
||||
<span class="details__tag">{{ info.duration }}</span>
|
||||
}
|
||||
@if (item.info?.rating_imdb) {
|
||||
@if (info.rating_imdb) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ item.info.rating_imdb }}
|
||||
⭐ {{ info.rating_imdb }}
|
||||
</span>
|
||||
}
|
||||
@if (item.info?.country) {
|
||||
<span class="details__tag">{{ item.info.country }}</span>
|
||||
@if (info.country) {
|
||||
<span class="details__tag">{{ info.country }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container hero-meta>
|
||||
@if (item.info?.actors || item.info?.cast) {
|
||||
@if (info.actors || info.cast) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
<span class="value">{{
|
||||
item.info.actors || item.info.cast
|
||||
}}</span>
|
||||
<span class="value">{{ info.actors || info.cast }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.director) {
|
||||
@if (info.director) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ item.info.director }}</span>
|
||||
<span class="value">{{ info.director }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.rating_kinopoisk) {
|
||||
@if (info.rating_kinopoisk) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.KINOPOISK_RATING' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ item.info.rating_kinopoisk }}</span>
|
||||
<span class="value">{{ info.rating_kinopoisk }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
@@ -159,14 +157,14 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (item.info?.youtube_trailer) {
|
||||
@if (info.youtube_trailer) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="
|
||||
'https://www.youtube.com/embed/' +
|
||||
item.info.youtube_trailer | safe
|
||||
info.youtube_trailer | safe
|
||||
"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
@@ -177,6 +175,56 @@
|
||||
</div>
|
||||
}
|
||||
</app-content-hero>
|
||||
} @else if (fallbackView(); as fallback) {
|
||||
<app-content-hero
|
||||
[title]="fallback.title"
|
||||
[posterUrl]="fallback.posterUrl"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<ng-container hero-tags>
|
||||
<span
|
||||
class="details__tag details__tag--status"
|
||||
data-testid="xtream-vod-fallback-status"
|
||||
>
|
||||
{{ 'XTREAM.DETAIL_FALLBACK.STATUS' | translate }}
|
||||
</span>
|
||||
@if (fallback.year) {
|
||||
<span class="details__tag">{{ fallback.year }}</span>
|
||||
}
|
||||
@if (fallback.rating) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ fallback.rating }}
|
||||
</span>
|
||||
}
|
||||
@if (fallback.added) {
|
||||
<span class="details__tag">{{ fallback.added }}</span>
|
||||
}
|
||||
@if (fallback.format) {
|
||||
<span class="details__tag">{{ fallback.format }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container hero-meta>
|
||||
<div
|
||||
class="details__fallback-note"
|
||||
data-testid="xtream-vod-fallback"
|
||||
>
|
||||
{{ 'XTREAM.DETAIL_FALLBACK.NOTE' | translate }}
|
||||
</div>
|
||||
|
||||
@for (fact of fallback.facts; track fact.labelKey) {
|
||||
<div class="details__meta-item details__meta-item--fallback">
|
||||
<span class="label">{{ fact.labelKey | translate }}</span>
|
||||
<span
|
||||
class="value"
|
||||
[class.value--mono]="fact.monospace"
|
||||
>
|
||||
{{ fact.value }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</app-content-hero>
|
||||
} @else if (detailsError()) {
|
||||
<app-content-hero
|
||||
[errorMessage]="detailsError()"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
:host {
|
||||
::ng-deep .details__tag--status {
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--accent-color) 28%, transparent);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--accent-color) 12%, transparent) 0%,
|
||||
var(--tag-bg) 100%
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
::ng-deep .details__fallback-note {
|
||||
flex: 1 0 100%;
|
||||
margin-bottom: 4px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--tag-border) 85%, transparent);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-bg) 86%, transparent) 0%,
|
||||
color-mix(in srgb, var(--tag-bg) 86%, transparent) 100%
|
||||
);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
::ng-deep .details__meta-item--fallback {
|
||||
min-width: min(220px, 100%);
|
||||
|
||||
.value--mono {
|
||||
font-family: ui-monospace, 'SFMono-Regular', 'SF Mono', Menlo,
|
||||
Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { Location } from '@angular/common';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
XtreamCategory,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
} from 'shared-interfaces';
|
||||
import { DownloadsService, SettingsStore } from 'services';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
|
||||
describe('VodDetailsRouteComponent', () => {
|
||||
let fixture: ComponentFixture<VodDetailsRouteComponent>;
|
||||
const selectedItem = signal<XtreamVodDetails | null>(null);
|
||||
const isLoadingDetails = signal(false);
|
||||
const detailsError = signal<string | null>(null);
|
||||
const isFavorite = signal(false);
|
||||
const currentPlaylist = signal<{
|
||||
id: string;
|
||||
userAgent?: string;
|
||||
referrer?: string;
|
||||
origin?: string;
|
||||
} | null>(null);
|
||||
const vodStreams = signal<Partial<XtreamVodStream>[]>([]);
|
||||
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
|
||||
const fetchVodDetailsWithMetadata = jest.fn();
|
||||
const checkFavoriteStatus = jest.fn();
|
||||
const setSelectedItem = jest.fn();
|
||||
const toggleFavorite = jest.fn();
|
||||
const constructVodStreamUrl = jest.fn().mockReturnValue(
|
||||
'http://example.com/movie/650020.mp4'
|
||||
);
|
||||
const addRecentItem = jest.fn();
|
||||
const downloads = signal([]);
|
||||
const getPlaybackPosition = jest.fn().mockResolvedValue(null);
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedItem.set(null);
|
||||
isLoadingDetails.set(false);
|
||||
detailsError.set(null);
|
||||
isFavorite.set(false);
|
||||
currentPlaylist.set(null);
|
||||
vodStreams.set([]);
|
||||
vodCategories.set([]);
|
||||
fetchVodDetailsWithMetadata.mockClear();
|
||||
checkFavoriteStatus.mockClear();
|
||||
setSelectedItem.mockClear();
|
||||
toggleFavorite.mockClear();
|
||||
constructVodStreamUrl.mockClear();
|
||||
addRecentItem.mockClear();
|
||||
getPlaybackPosition.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodDetailsRouteComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
params: {
|
||||
vodId: '650020',
|
||||
categoryId: '235',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: {
|
||||
selectedItem,
|
||||
isLoadingDetails,
|
||||
detailsError,
|
||||
isFavorite,
|
||||
currentPlaylist,
|
||||
vodStreams,
|
||||
vodCategories,
|
||||
fetchVodDetailsWithMetadata,
|
||||
checkFavoriteStatus,
|
||||
setSelectedItem,
|
||||
toggleFavorite,
|
||||
constructVodStreamUrl,
|
||||
addRecentItem,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
theme: signal('dark'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
downloads,
|
||||
isDownloaded: jest.fn().mockReturnValue(false),
|
||||
isDownloading: jest.fn().mockReturnValue(false),
|
||||
startDownload: jest.fn(),
|
||||
getDownloadedFilePath: jest.fn(),
|
||||
playDownload: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: {
|
||||
activeSession: signal(null),
|
||||
closeSession: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getPlaybackPosition,
|
||||
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Location,
|
||||
useValue: {
|
||||
back: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VodDetailsRouteComponent);
|
||||
});
|
||||
|
||||
it('renders an informational fallback without playback controls when Xtream returns empty metadata', () => {
|
||||
selectedItem.set({
|
||||
info: [],
|
||||
} as XtreamVodDetails);
|
||||
vodStreams.set([
|
||||
{
|
||||
name: 'Die Kühe sind Los! (2004) DE',
|
||||
stream_id: 650020,
|
||||
stream_icon: 'https://example.com/cows.jpg',
|
||||
added: '1720000000',
|
||||
category_id: '235',
|
||||
container_extension: 'mp4',
|
||||
rating: 6.1,
|
||||
rating_imdb: '6.1',
|
||||
},
|
||||
]);
|
||||
vodCategories.set([
|
||||
{
|
||||
category_id: '235',
|
||||
category_name: 'DE | DISNEY',
|
||||
},
|
||||
]);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.textContent).toContain('Die Kühe sind Los! (2004) DE');
|
||||
expect(
|
||||
host.querySelector('[data-testid="xtream-vod-fallback"]')?.textContent
|
||||
).toContain('XTREAM.DETAIL_FALLBACK.NOTE');
|
||||
expect(
|
||||
host.querySelector('[data-testid="xtream-vod-fallback-status"]')
|
||||
?.textContent
|
||||
).toContain('XTREAM.DETAIL_FALLBACK.STATUS');
|
||||
expect(host.querySelector('button.play-btn')).toBeNull();
|
||||
expect(host.querySelector('button.favorite-btn')).toBeNull();
|
||||
expect(host.querySelector('button.download-btn')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the full Xtream detail view when usable metadata exists', () => {
|
||||
selectedItem.set({
|
||||
info: {
|
||||
kinopoisk_url: '',
|
||||
tmdb_id: 228203,
|
||||
name: 'City of McFarland (2015)',
|
||||
o_name: 'City of McFarland (2015)',
|
||||
cover_big: 'https://example.com/poster-big.jpg',
|
||||
movie_image: 'https://example.com/poster.jpg',
|
||||
releasedate: '2015-02-20',
|
||||
episode_run_time: 129,
|
||||
youtube_trailer: '',
|
||||
director: 'Niki Caro',
|
||||
actors: 'Kevin Costner',
|
||||
cast: 'Kevin Costner',
|
||||
description: 'A populated description',
|
||||
plot: 'A populated plot',
|
||||
age: '',
|
||||
mpaa_rating: '',
|
||||
rating_count_kinopoisk: 0,
|
||||
country: 'English',
|
||||
genre: 'Drama',
|
||||
backdrop_path: ['https://example.com/backdrop.jpg'],
|
||||
duration_secs: 7744,
|
||||
duration: '02:09:04',
|
||||
video: ['H.264'],
|
||||
audio: ['AAC'],
|
||||
bitrate: 6251,
|
||||
rating: 7.455,
|
||||
rating_imdb: '7.455',
|
||||
rating_kinopoisk: '7.455',
|
||||
},
|
||||
movie_data: {
|
||||
stream_id: 678140,
|
||||
name: 'City of McFarland (2015) DE',
|
||||
added: '1750671180',
|
||||
category_id: '235',
|
||||
container_extension: 'mkv',
|
||||
custom_sid: null,
|
||||
direct_source: '',
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.textContent).toContain('City of McFarland (2015)');
|
||||
expect(host.querySelector('[data-testid="xtream-vod-fallback"]')).toBeNull();
|
||||
expect(host.querySelector('button.play-btn')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,12 +28,22 @@ import {
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
ResolvedPortalPlayback,
|
||||
XtreamCategory,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
getXtreamVodInfo,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildXtreamVodFallbackViewModel,
|
||||
hasUsableXtreamVodMetadata,
|
||||
} from './vod-details-fallback.util';
|
||||
|
||||
@Component({
|
||||
templateUrl: './vod-details-route.component.html',
|
||||
styleUrls: ['../../../../../../ui/components/src/lib/styles/detail-view.scss'],
|
||||
styleUrls: [
|
||||
'../../../../../../ui/components/src/lib/styles/detail-view.scss',
|
||||
'./vod-details-route.component.scss',
|
||||
],
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
MatIcon,
|
||||
@@ -67,6 +77,87 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
() =>
|
||||
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null
|
||||
);
|
||||
readonly selectedVodId = computed(() =>
|
||||
Number(this.route.snapshot.params.vodId)
|
||||
);
|
||||
readonly selectedCategory = computed<Partial<XtreamCategory> | null>(() => {
|
||||
const categoryId = this.route.snapshot.params.categoryId;
|
||||
if (!categoryId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
this.xtreamStore
|
||||
.vodCategories()
|
||||
.find(
|
||||
(category) =>
|
||||
String(
|
||||
(category as XtreamCategory & { id?: string | number })
|
||||
.category_id ??
|
||||
(category as XtreamCategory & { id?: string | number })
|
||||
.id
|
||||
) === String(categoryId)
|
||||
) ?? null
|
||||
);
|
||||
});
|
||||
readonly selectedCatalogItem = computed<
|
||||
(Partial<XtreamVodStream> & {
|
||||
id?: string | number;
|
||||
poster_url?: string;
|
||||
title?: string;
|
||||
xtream_id?: string | number;
|
||||
}) | null
|
||||
>(() => {
|
||||
const vodId = this.selectedVodId();
|
||||
if (!Number.isFinite(vodId) || vodId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
this.xtreamStore
|
||||
.vodStreams()
|
||||
.find(
|
||||
(item) =>
|
||||
Number(
|
||||
(
|
||||
item as XtreamVodStream & {
|
||||
id?: string | number;
|
||||
xtream_id?: string | number;
|
||||
}
|
||||
).xtream_id ??
|
||||
(
|
||||
item as XtreamVodStream & {
|
||||
id?: string | number;
|
||||
}
|
||||
).stream_id ??
|
||||
(
|
||||
item as XtreamVodStream & {
|
||||
id?: string | number;
|
||||
}
|
||||
).id
|
||||
) === vodId
|
||||
) ?? null
|
||||
);
|
||||
});
|
||||
readonly selectedVodInfo = computed(() => {
|
||||
const item = this.selectedItem();
|
||||
return item && hasUsableXtreamVodMetadata(item)
|
||||
? getXtreamVodInfo(item)
|
||||
: null;
|
||||
});
|
||||
readonly fallbackView = computed(() => {
|
||||
const item = this.selectedItem();
|
||||
if (!item || this.selectedVodInfo()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildXtreamVodFallbackViewModel({
|
||||
vodDetails: item,
|
||||
catalogItem: this.selectedCatalogItem(),
|
||||
category: this.selectedCategory(),
|
||||
vodId: this.selectedVodId(),
|
||||
});
|
||||
});
|
||||
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
|
||||
readonly detailsError = this.xtreamStore.detailsError;
|
||||
private lastSaveTime = 0;
|
||||
@@ -216,6 +307,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
playVod(vodItem: XtreamVodDetails): void {
|
||||
const info = getXtreamVodInfo(vodItem);
|
||||
this.addToRecentlyViewed();
|
||||
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
|
||||
const routeVodId = this.route.snapshot.params.vodId;
|
||||
@@ -235,8 +327,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl,
|
||||
title: vodItem.info.name ?? vodItem.movie_data?.name,
|
||||
thumbnail: vodItem.info.movie_image,
|
||||
title: info?.name ?? vodItem.movie_data?.name,
|
||||
thumbnail: info?.movie_image,
|
||||
contentInfo,
|
||||
};
|
||||
|
||||
@@ -244,6 +336,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
resumeVod(vodItem: XtreamVodDetails): void {
|
||||
const info = getXtreamVodInfo(vodItem);
|
||||
this.addToRecentlyViewed();
|
||||
const vodId = Number(this.route.snapshot.params.vodId);
|
||||
const position = this.vodPlaybackPosition();
|
||||
@@ -256,8 +349,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl,
|
||||
title: vodItem.info.name ?? vodItem.movie_data?.name,
|
||||
thumbnail: vodItem.info.movie_image,
|
||||
title: info?.name ?? vodItem.movie_data?.name,
|
||||
thumbnail: info?.movie_image,
|
||||
startTime: position?.positionSeconds,
|
||||
contentInfo,
|
||||
};
|
||||
@@ -346,6 +439,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async downloadVod(vodItem: XtreamVodDetails): Promise<void> {
|
||||
const info = getXtreamVodInfo(vodItem);
|
||||
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
|
||||
const routeVodId = this.route.snapshot.params.vodId;
|
||||
const id = routeVodId
|
||||
@@ -361,9 +455,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
playlistId: playlist.id,
|
||||
xtreamId: id,
|
||||
contentType: 'vod',
|
||||
title: vodItem.info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
|
||||
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
|
||||
url: streamUrl,
|
||||
posterUrl: vodItem.info?.movie_image,
|
||||
posterUrl: info?.movie_image,
|
||||
headers: {
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { XtreamVodDetails } from './xtream-vod-details.interface';
|
||||
import { XtreamVodDetails, getXtreamVodInfo } from './xtream-vod-details.interface';
|
||||
import { StalkerVodDetails } from './stalker-vod-details.interface';
|
||||
import {
|
||||
NormalizedVodMeta,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
* Handles the various field name variations in Xtream API responses.
|
||||
*/
|
||||
export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
|
||||
const info = item?.info;
|
||||
const info = getXtreamVodInfo(item);
|
||||
const movieData = item?.movie_data;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,41 +1,64 @@
|
||||
export interface XtreamVodDetails {
|
||||
info: {
|
||||
kinopoisk_url: string;
|
||||
tmdb_id: number;
|
||||
name: string;
|
||||
o_name: string;
|
||||
cover_big: string;
|
||||
movie_image: string;
|
||||
releasedate: string;
|
||||
episode_run_time: number;
|
||||
youtube_trailer: string;
|
||||
director: string;
|
||||
actors: string;
|
||||
cast: string;
|
||||
description: string;
|
||||
plot: string;
|
||||
age: string;
|
||||
mpaa_rating: string;
|
||||
rating_count_kinopoisk: number;
|
||||
country: string;
|
||||
genre: string;
|
||||
backdrop_path: string[];
|
||||
duration_secs: number;
|
||||
duration: string;
|
||||
video: string[];
|
||||
audio: string[];
|
||||
bitrate: number;
|
||||
rating: number;
|
||||
rating_kinopoisk?: string;
|
||||
rating_imdb?: string;
|
||||
};
|
||||
movie_data: {
|
||||
stream_id: number;
|
||||
name: string;
|
||||
added: string;
|
||||
category_id: string;
|
||||
container_extension: string;
|
||||
custom_sid: string;
|
||||
direct_source: string;
|
||||
};
|
||||
export interface XtreamVodInfo {
|
||||
kinopoisk_url: string;
|
||||
tmdb_id: number | string;
|
||||
name: string;
|
||||
o_name: string;
|
||||
cover_big: string;
|
||||
movie_image: string;
|
||||
releasedate: string;
|
||||
episode_run_time: number;
|
||||
youtube_trailer: string;
|
||||
director: string;
|
||||
actors: string;
|
||||
cast: string;
|
||||
description: string;
|
||||
plot: string;
|
||||
age: string;
|
||||
mpaa_rating: string;
|
||||
rating_count_kinopoisk: number;
|
||||
country: string;
|
||||
genre: string;
|
||||
backdrop_path: string[];
|
||||
duration_secs: number;
|
||||
duration: string;
|
||||
video: string[];
|
||||
audio: string[];
|
||||
bitrate: number;
|
||||
rating: number | string;
|
||||
runtime?: string;
|
||||
status?: string;
|
||||
rating_kinopoisk?: string;
|
||||
rating_imdb?: string;
|
||||
}
|
||||
|
||||
export interface XtreamVodMovieData {
|
||||
stream_id: number;
|
||||
name: string;
|
||||
added: string;
|
||||
category_id: string;
|
||||
container_extension: string;
|
||||
custom_sid: string | null;
|
||||
direct_source: string;
|
||||
category_ids?: number[];
|
||||
}
|
||||
|
||||
export interface XtreamVodDetails {
|
||||
info?: XtreamVodInfo | [] | null;
|
||||
movie_data?: XtreamVodMovieData;
|
||||
}
|
||||
|
||||
export function getXtreamVodInfo(
|
||||
details: Pick<XtreamVodDetails, 'info'> | null | undefined
|
||||
): XtreamVodInfo | null {
|
||||
const info = details?.info;
|
||||
if (!info || Array.isArray(info) || typeof info !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
export function hasXtreamVodInfo(
|
||||
details: Pick<XtreamVodDetails, 'info'> | null | undefined
|
||||
): details is XtreamVodDetails & { info: XtreamVodInfo } {
|
||||
return getXtreamVodInfo(details) !== null;
|
||||
}
|
||||
Reference in new issue
Block a user