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:
4gray committed 2026-04-11 22:32:50 +02:00
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;
}