diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index 22196085a..dcf62da97 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -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}`; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 3ff704f0a..7f48046fb 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -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; diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index c77c0ff81..21b9b3177 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -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) => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-fallback.util.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-fallback.util.spec.ts new file mode 100644 index 000000000..65f03b9ef --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-fallback.util.spec.ts @@ -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; + + 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, + }); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-fallback.util.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-fallback.util.ts new file mode 100644 index 000000000..a688badcf --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-fallback.util.ts @@ -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 & { + 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 & { + 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; + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 5de5b8c03..eb7f765fb 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -1,57 +1,55 @@ @let item = selectedItem(); -@if (item && item.info) { +@if (selectedVodInfo(); as info) { - @if (item.info?.releasedate) { + @if (info.releasedate) { {{ - item.info.releasedate | slice: 0 : 4 + info.releasedate | slice: 0 : 4 }} } - @if (item.info?.genre) { - {{ item.info.genre }} + @if (info.genre) { + {{ info.genre }} } - @if (item.info?.duration) { - {{ item.info.duration }} + @if (info.duration) { + {{ info.duration }} } - @if (item.info?.rating_imdb) { + @if (info.rating_imdb) { - ⭐ {{ item.info.rating_imdb }} + ⭐ {{ info.rating_imdb }} } - @if (item.info?.country) { - {{ item.info.country }} + @if (info.country) { + {{ info.country }} } - @if (item.info?.actors || item.info?.cast) { + @if (info.actors || info.cast) {
{{ 'XTREAM.ACTORS' | translate }} - {{ - item.info.actors || item.info.cast - }} + {{ info.actors || info.cast }}
} - @if (item.info?.director) { + @if (info.director) {
{{ 'XTREAM.DIRECTOR' | translate }} - {{ item.info.director }} + {{ info.director }}
} - @if (item.info?.rating_kinopoisk) { + @if (info.rating_kinopoisk) {
{{ 'XTREAM.KINOPOISK_RATING' | translate }} - {{ item.info.rating_kinopoisk }} + {{ info.rating_kinopoisk }}
}
@@ -159,14 +157,14 @@ /> } - @if (item.info?.youtube_trailer) { + @if (info.youtube_trailer) {

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}