fix(xtream): keep sparse VOD details playable

This commit is contained in:
4gray committed 2026-07-29 00:17:59 +02:00
1 parent 3c342bc555
commit ec6df196f5
27 files changed
+1367 -239

No files matched your search

@@ -3,4 +3,5 @@ export * from './favorite-item.interface';
export * from './favorites.service';
export * from './xtream-api.service';
export * from './xtream-url.service';
export * from './xtream-vod-playback-source';
export * from './xtream-xmltv-fallback.service';
@@ -115,6 +115,43 @@ describe('XtreamApiService', () => {
);
});
it('loads one VOD catalog item from its category', async () => {
dataService.sendIpcEvent.mockResolvedValue({
payload: [
{
stream_id: 41,
category_id: '7',
container_extension: 'mkv',
},
{
stream_id: 42,
category_id: '7',
container_extension: 'mp4',
},
],
});
const item = await service.getVodStream(credentials, 42, 7);
expect(item).toEqual(
expect.objectContaining({
stream_id: 42,
container_extension: 'mp4',
})
);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
XTREAM_REQUEST,
expect.objectContaining({
params: {
action: 'get_vod_streams',
category_id: '7',
password: 'secret',
username: 'demo',
},
})
);
});
it('falls back to the legacy full-epg action and normalizes the response', async () => {
dataService.sendIpcEvent.mockImplementation(
async (_type: string, payload: unknown) => {
@@ -41,6 +41,10 @@ export interface XtreamRequestOptions {
suppressErrorLog?: boolean;
}
export interface XtreamVodCatalogRequestOptions extends XtreamRequestOptions {
categoryId?: string | number;
}
const XTREAM_ACCOUNT_ACTIONS = [
XtreamCodeActions.GetAccountInfo,
null,
@@ -191,7 +195,7 @@ export class XtreamApiService {
*/
async getVodStreams(
credentials: XtreamCredentials,
options?: XtreamRequestOptions
options?: XtreamVodCatalogRequestOptions
): Promise<XtreamVodStream[]> {
const response = await this.sendRequest(
credentials.serverUrl,
@@ -199,6 +203,9 @@ export class XtreamApiService {
action: XtreamCodeActions.GetVodStreams,
username: credentials.username,
password: credentials.password,
...(options?.categoryId !== undefined
? { category_id: options.categoryId }
: {}),
},
options
);
@@ -206,6 +213,24 @@ export class XtreamApiService {
return Array.isArray(response) ? response : [];
}
async getVodStream(
credentials: XtreamCredentials,
vodId: string | number,
categoryId: string | number,
options?: XtreamRequestOptions
): Promise<XtreamVodStream | null> {
const streams = await this.getVodStreams(credentials, {
...options,
categoryId,
});
return (
streams.find(
(stream) => Number(stream.stream_id) === Number(vodId)
) ?? null
);
}
/**
* Get series items
*/
@@ -120,6 +120,21 @@ describe('XtreamUrlService', () => {
expect(url).toBe('http://demo.example/live/demo/secret/101.ts');
});
it('constructs a VOD URL from catalog fields when metadata is empty', () => {
const vodItem = {
info: [],
stream_id: 101,
container_extension: 'mp4',
} as XtreamVodDetails & {
stream_id: number;
container_extension: string;
};
expect(service.constructVodUrl(credentials, vodItem)).toBe(
'http://demo.example/movie/demo/secret/101.mp4'
);
});
it('returns empty stream URLs instead of throwing for invalid stored server URLs', () => {
const invalidCredentials: XtreamCredentials = {
...credentials,
@@ -7,6 +7,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { DatabaseService, SettingsStore } from '@iptvnator/services';
import { XtreamCredentials } from './xtream-api.service';
import { resolveXtreamVodPlaybackSource } from './xtream-vod-playback-source';
/**
* Extended playlist with optional HTTP headers
@@ -28,10 +29,6 @@ export interface LiveStreamItem {
[key: string]: unknown;
}
type XtreamVodStreamLike = XtreamVodDetails & {
readonly stream_id?: number;
};
const XTREAM_CATCHUP_SCHEME = {
LEGACY: 'legacy',
REST: 'rest',
@@ -124,10 +121,8 @@ export class XtreamUrlService {
credentials: XtreamCredentials,
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;
if (!streamId || !extension) {
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return '';
}
const normalizedCredentials = this.normalizeCredentials(credentials);
@@ -135,7 +130,7 @@ export class XtreamUrlService {
return '';
}
return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${streamId}.${extension}`;
return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${source.streamId}.${source.containerExtension}`;
}
/**
@@ -0,0 +1,72 @@
import { XtreamVodDetails } from '@iptvnator/shared/interfaces';
import {
resolveXtreamVodPlaybackSource,
XtreamVodPlaybackItem,
} from './xtream-vod-playback-source';
function createMovieDataDetails(
overrides: Partial<NonNullable<XtreamVodDetails['movie_data']>> = {}
): XtreamVodDetails {
return {
movie_data: {
added: '',
category_id: '',
container_extension: 'mp4',
custom_sid: null,
direct_source: '',
name: 'Movie',
stream_id: 101,
...overrides,
},
};
}
describe('resolveXtreamVodPlaybackSource', () => {
it('resolves a source from movie_data', () => {
expect(
resolveXtreamVodPlaybackSource(createMovieDataDetails())
).toEqual({
streamId: 101,
containerExtension: 'mp4',
});
});
it('falls back to catalog fields', () => {
const item: XtreamVodPlaybackItem = {
info: [],
stream_id: '202',
container_extension: ' mkv ',
};
expect(resolveXtreamVodPlaybackSource(item)).toEqual({
streamId: 202,
containerExtension: 'mkv',
});
});
it('prefers movie_data over catalog fields', () => {
const item: XtreamVodPlaybackItem = {
...createMovieDataDetails(),
stream_id: 202,
container_extension: 'mkv',
};
expect(resolveXtreamVodPlaybackSource(item)).toEqual({
streamId: 101,
containerExtension: 'mp4',
});
});
it.each([
undefined,
{},
{ stream_id: 0, container_extension: 'mp4' },
{ stream_id: -1, container_extension: 'mp4' },
{ stream_id: 1.5, container_extension: 'mp4' },
{ stream_id: 'not-an-id', container_extension: 'mp4' },
{ stream_id: 101 },
{ stream_id: 101, container_extension: ' ' },
] as const)('returns null for an unusable item %#', (item) => {
expect(resolveXtreamVodPlaybackSource(item)).toBeNull();
});
});
@@ -0,0 +1,51 @@
import { XtreamVodDetails } from '@iptvnator/shared/interfaces';
export type XtreamVodPlaybackItem = XtreamVodDetails & {
readonly stream_id?: number | string | null;
readonly container_extension?: string | null;
};
export interface XtreamVodPlaybackSource {
readonly streamId: number;
readonly containerExtension: string;
}
function resolveCandidate(
streamIdValue: unknown,
containerExtensionValue: unknown
): XtreamVodPlaybackSource | null {
const streamId =
typeof streamIdValue === 'string' && streamIdValue.trim()
? Number(streamIdValue)
: streamIdValue;
const containerExtension =
typeof containerExtensionValue === 'string'
? containerExtensionValue.trim()
: '';
if (
typeof streamId !== 'number' ||
!Number.isSafeInteger(streamId) ||
streamId <= 0 ||
!containerExtension
) {
return null;
}
return { streamId, containerExtension };
}
export function resolveXtreamVodPlaybackSource(
item: XtreamVodPlaybackItem | null | undefined
): XtreamVodPlaybackSource | null {
if (!item) {
return null;
}
return (
resolveCandidate(
item.movie_data?.stream_id,
item.movie_data?.container_extension
) ?? resolveCandidate(item.stream_id, item.container_extension)
);
}
@@ -0,0 +1,118 @@
import {
createXtreamDetailsRequestGuard,
recoverXtreamVodCatalogItem,
} from './xtream-details-request';
describe('Xtream details request coordination', () => {
const credentials = {
serverUrl: 'http://demo.example',
username: 'demo',
password: 'secret',
};
it('invalidates older detail requests and requests from another playlist', () => {
let currentPlaylistId = 'playlist-a';
const guard = createXtreamDetailsRequestGuard(() => currentPlaylistId);
const firstRequestIsCurrent = guard.begin('playlist-a');
const secondRequestIsCurrent = guard.begin('playlist-a');
expect(firstRequestIsCurrent()).toBe(false);
expect(secondRequestIsCurrent()).toBe(true);
currentPlaylistId = 'playlist-b';
expect(secondRequestIsCurrent()).toBe(false);
const thirdRequestIsCurrent = guard.begin('playlist-b');
guard.invalidate();
expect(thirdRequestIsCurrent()).toBe(false);
});
it('recovers a provider category id from hidden Electron categories', async () => {
const getAllCategories = jest.fn().mockResolvedValue([
{
id: 7,
xtream_id: 701,
},
]);
const getCategories = jest.fn().mockResolvedValue([]);
const getVodStream = jest.fn().mockResolvedValue({
stream_id: 42,
container_extension: 'mp4',
});
await expect(
recoverXtreamVodCatalogItem({
apiService: { getVodStream },
currentCategories: [],
credentials,
dataSource: { getAllCategories, getCategories },
isCurrent: () => true,
playlistId: 'playlist-a',
routeCategoryId: 7,
vodId: 42,
})
).resolves.toEqual({
stream_id: 42,
container_extension: 'mp4',
});
expect(getAllCategories).toHaveBeenCalledWith('playlist-a', 'movies');
expect(getCategories).not.toHaveBeenCalled();
expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701);
});
it('falls back to the visible API categories when PWA has no persisted categories', async () => {
const getAllCategories = jest.fn().mockResolvedValue([]);
const getCategories = jest
.fn()
.mockResolvedValue([{ category_id: 701 }]);
const getVodStream = jest.fn().mockResolvedValue({
stream_id: 42,
container_extension: 'mp4',
});
await recoverXtreamVodCatalogItem({
apiService: { getVodStream },
currentCategories: [],
credentials,
dataSource: { getAllCategories, getCategories },
isCurrent: () => true,
playlistId: 'playlist-a',
routeCategoryId: 701,
vodId: 42,
});
expect(getCategories).toHaveBeenCalledWith(
'playlist-a',
credentials,
'vod'
);
expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701);
});
it('drops a catalog recovery result after a newer request starts', async () => {
let isCurrent = true;
const getVodStream = jest.fn().mockImplementation(async () => {
isCurrent = false;
return {
stream_id: 42,
container_extension: 'mp4',
};
});
await expect(
recoverXtreamVodCatalogItem({
apiService: { getVodStream },
currentCategories: [{ category_id: 701 }],
credentials,
dataSource: {
getAllCategories: jest.fn(),
getCategories: jest.fn(),
},
isCurrent: () => isCurrent,
playlistId: 'playlist-a',
routeCategoryId: 701,
vodId: 42,
})
).resolves.toBeNull();
});
});
@@ -0,0 +1,112 @@
import { XtreamVodStream } from '@iptvnator/shared/interfaces';
import { XtreamCredentials } from '../services/xtream-api.service';
import {
resolveXtreamVodCatalogCategoryId,
XtreamVodCatalogCategory,
} from './xtream-vod-selection';
type PlaylistId = string | null | undefined;
export function createXtreamDetailsRequestGuard(
getCurrentPlaylistId: () => PlaylistId
) {
let generation = 0;
return {
begin(expectedPlaylistId: string): () => boolean {
const requestGeneration = ++generation;
return () =>
requestGeneration === generation &&
getCurrentPlaylistId() === expectedPlaylistId;
},
invalidate(): void {
generation += 1;
},
};
}
interface RecoverXtreamVodCatalogItemOptions {
readonly apiService: {
getVodStream(
credentials: XtreamCredentials,
vodId: string | number,
categoryId: string | number
): Promise<XtreamVodStream | null>;
};
readonly currentCategories: readonly XtreamVodCatalogCategory[];
readonly credentials: XtreamCredentials;
readonly dataSource: {
getAllCategories(
playlistId: string,
type: 'movies'
): Promise<readonly XtreamVodCatalogCategory[]>;
getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: 'vod'
): Promise<readonly XtreamVodCatalogCategory[]>;
};
readonly isCurrent: () => boolean;
readonly playlistId: string;
readonly routeCategoryId: string | number;
readonly vodId: string | number;
}
export async function recoverXtreamVodCatalogItem({
apiService,
currentCategories,
credentials,
dataSource,
isCurrent,
playlistId,
routeCategoryId,
vodId,
}: RecoverXtreamVodCatalogItemOptions): Promise<XtreamVodStream | null> {
if (!isCurrent()) {
return null;
}
let providerCategoryId = resolveXtreamVodCatalogCategoryId(
currentCategories,
routeCategoryId
);
if (providerCategoryId === null) {
const persistedCategories = await dataSource.getAllCategories(
playlistId,
'movies'
);
if (!isCurrent()) {
return null;
}
providerCategoryId = resolveXtreamVodCatalogCategoryId(
persistedCategories,
routeCategoryId
);
}
if (providerCategoryId === null) {
const categories = await dataSource.getCategories(
playlistId,
credentials,
'vod'
);
if (!isCurrent()) {
return null;
}
providerCategoryId = resolveXtreamVodCatalogCategoryId(
categories,
routeCategoryId
);
}
if (providerCategoryId === null || !isCurrent()) {
return null;
}
const item = await apiService.getVodStream(
credentials,
vodId,
providerCategoryId
);
return isCurrent() ? item : null;
}
@@ -0,0 +1,41 @@
import { XtreamVodStream } from '@iptvnator/shared/interfaces';
import {
buildXtreamVodSelection,
resolveXtreamVodCatalogCategoryId,
} from './xtream-vod-selection';
describe('Xtream VOD selection helpers', () => {
it('merges a sparse detail response with its catalog playback fields', () => {
const catalogItem = {
stream_id: 42,
container_extension: 'mp4',
name: 'Catalog title',
stream_icon: 'catalog-poster.jpg',
xtream_id: 42,
} as XtreamVodStream;
expect(buildXtreamVodSelection({ info: [] }, catalogItem, 42)).toEqual(
expect.objectContaining({
info: [],
stream_id: 42,
container_extension: 'mp4',
name: 'Catalog title',
stream_icon: 'catalog-poster.jpg',
xtream_id: 42,
})
);
});
it('maps an Electron database category id back to the provider id', () => {
expect(
resolveXtreamVodCatalogCategoryId([{ id: 7, xtream_id: 701 }], 7)
).toBe(701);
expect(
resolveXtreamVodCatalogCategoryId([{ category_id: '702' }], '702')
).toBe('702');
});
it('does not treat an unresolved database category id as a provider id', () => {
expect(resolveXtreamVodCatalogCategoryId([], 7)).toBeNull();
});
});
@@ -0,0 +1,49 @@
import {
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { XtreamVodPlaybackItem } from '../services/xtream-vod-playback-source';
export type XtreamVodSelection = XtreamVodDetails & {
[key: string]: unknown;
container_extension?: string | null;
stream_id: string | number;
xtream_id: number;
};
export interface XtreamVodCatalogCategory {
readonly category_id?: string | number;
readonly id?: string | number;
readonly xtream_id?: string | number;
}
export function buildXtreamVodSelection(
vodDetails: XtreamVodDetails,
catalogItem: XtreamVodStream | undefined,
vodId: string | number
): XtreamVodSelection {
const detailItem = vodDetails as XtreamVodPlaybackItem;
return {
...catalogItem,
...vodDetails,
stream_id:
catalogItem?.stream_id ?? detailItem.stream_id ?? Number(vodId),
container_extension:
catalogItem?.container_extension ??
detailItem.container_extension ??
null,
xtream_id: catalogItem?.xtream_id ?? Number(vodId),
};
}
export function resolveXtreamVodCatalogCategoryId(
categories: readonly XtreamVodCatalogCategory[],
categoryId: string | number
): string | number | null {
const category = categories.find(
(item) => Number(item.id ?? item.category_id) === Number(categoryId)
);
return category?.xtream_id ?? category?.category_id ?? null;
}
@@ -1,16 +1,16 @@
import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withMethods } from '@ngrx/signals';
import { XtreamSerieDetails, XtreamVodDetails } from '@iptvnator/shared/interfaces';
import {
XtreamSerieDetails,
XtreamVodDetails,
} from '@iptvnator/shared/interfaces';
// Import existing features that are already separate
import { withFavorites } from '../with-favorites.feature';
import { withRecentItems } from '../with-recent-items';
// Import service
import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../services/xtream-api.service';
// Import new feature stores
import { TmdbEnrichmentService } from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
@@ -27,25 +27,17 @@ import {
enrichSerialSelectionWithTmdb,
enrichVodSelectionWithTmdb,
} from './xtream-tmdb-enrichment';
import {
createXtreamDetailsRequestGuard,
recoverXtreamVodCatalogItem,
} from './xtream-details-request';
import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source';
import {
buildXtreamVodSelection,
XtreamVodCatalogCategory,
} from './xtream-vod-selection';
/**
* XtreamStore - Facade composing all feature stores.
*
* This store provides a unified API for components while delegating
* to specialized feature stores for different concerns:
*
* - withPortal: Playlist and portal status management
* - withContent: Categories and streams management
* - withSelection: UI selection and pagination
* - withSearch: Search functionality
* - withEpg: EPG (Electronic Program Guide) data
* - withPlayer: Stream URL construction and player integration
* - withFavorites: Favorites management
* - withRecentItems: Recently viewed items
* - withPlaybackPositions: Playback position tracking
*
* @see docs/XTREAM_STORE_REFACTORING_PLAN.md
*/
/** Facade composing the Xtream feature stores and cross-feature workflows. */
export const XtreamStore = signalStore(
{ providedIn: 'root' },
@@ -60,7 +52,6 @@ export const XtreamStore = signalStore(
withRecentItems(),
withPlaybackPositions(),
// Cross-feature computed properties
withComputed((store) => ({
/**
* Get global recent items (from withRecentItems)
@@ -76,6 +67,9 @@ export const XtreamStore = signalStore(
const dataSource = inject(XTREAM_DATA_SOURCE);
const tmdbEnrichment = inject(TmdbEnrichmentService);
const logger = createLogger('XtreamStore');
const detailsRequestGuard = createXtreamDetailsRequestGuard(
() => store.currentPlaylist()?.id
);
const findVodCatalogItem = (vodId: string | number) =>
store.vodStreams().find((item) => {
const candidateId =
@@ -85,12 +79,17 @@ export const XtreamStore = signalStore(
return Number(candidateId) === Number(vodId);
});
return {
cancelDetailsRequest(): void {
detailsRequestGuard.invalidate();
store.setIsLoadingDetails(false);
},
/**
* Full store reset for switching between playlists
*/
resetStore(newPlaylistId?: string): void {
detailsRequestGuard.invalidate();
// Clear the session cache for the playlist we're leaving so
// stale data cannot bleed into the new playlist (PWA path).
const leavingPlaylistId = store.playlistId();
@@ -148,31 +147,65 @@ export const XtreamStore = signalStore(
}): void {
const playlist = store.currentPlaylist();
if (!playlist) return;
const isCurrentRequest = detailsRequestGuard.begin(playlist.id);
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
store.setIsLoadingDetails(true);
store.setDetailsError(null);
xtreamApiService
.getVodInfo(
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
params.vodId
)
.then((vodDetails: XtreamVodDetails) => {
const catalogItem = findVodCatalogItem(params.vodId);
.getVodInfo(credentials, params.vodId)
.then(async (vodDetails: XtreamVodDetails) => {
if (!isCurrentRequest()) return;
let catalogItem = findVodCatalogItem(params.vodId);
let selection = buildXtreamVodSelection(
vodDetails,
catalogItem,
params.vodId
);
if (!resolveXtreamVodPlaybackSource(selection)) {
try {
const remoteCatalogItem =
await recoverXtreamVodCatalogItem({
apiService: xtreamApiService,
currentCategories:
store.vodCategories() as unknown as XtreamVodCatalogCategory[],
credentials,
dataSource,
isCurrent: isCurrentRequest,
playlistId: playlist.id,
routeCategoryId: params.categoryId,
vodId: params.vodId,
});
if (remoteCatalogItem) {
catalogItem = {
...catalogItem,
...remoteCatalogItem,
};
selection = buildXtreamVodSelection(
vodDetails,
catalogItem,
params.vodId
);
}
} catch (error) {
if (isCurrentRequest()) {
logger.warn(
'Failed to recover sparse VOD playback source from the catalog',
error
);
}
}
}
if (!isCurrentRequest()) return;
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...catalogItem,
...vodDetails,
stream_id: params.vodId,
xtream_id:
catalogItem?.xtream_id ?? Number(params.vodId),
});
// Async, best-effort: patches the selection with a
// field-level TMDB merge once metadata arrives
store.setSelectedItem(selection);
void enrichVodSelectionWithTmdb(
store,
tmdbEnrichment,
@@ -180,6 +213,7 @@ export const XtreamStore = signalStore(
);
})
.catch((error: unknown) => {
if (!isCurrentRequest()) return;
logger.error('Error fetching VOD details', error);
store.setDetailsError(
error instanceof Error
@@ -188,7 +222,9 @@ export const XtreamStore = signalStore(
);
})
.finally(() => {
store.setIsLoadingDetails(false);
if (isCurrentRequest()) {
store.setIsLoadingDetails(false);
}
});
},
@@ -202,19 +238,19 @@ export const XtreamStore = signalStore(
}): void {
const playlist = store.currentPlaylist();
if (!playlist) return;
const isCurrentRequest = detailsRequestGuard.begin(playlist.id);
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
store.setIsLoadingDetails(true);
store.setDetailsError(null);
xtreamApiService
.getSeriesInfo(
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
params.serialId
)
.getSeriesInfo(credentials, params.serialId)
.then((serialDetails: XtreamSerieDetails) => {
if (!isCurrentRequest()) return;
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...serialDetails,
@@ -227,6 +263,7 @@ export const XtreamStore = signalStore(
);
})
.catch((error: unknown) => {
if (!isCurrentRequest()) return;
logger.error('Error fetching series details', error);
store.setDetailsError(
error instanceof Error
@@ -235,7 +272,9 @@ export const XtreamStore = signalStore(
);
})
.finally(() => {
store.setIsLoadingDetails(false);
if (isCurrentRequest()) {
store.setIsLoadingDetails(false);
}
});
},
@@ -88,6 +88,7 @@ describe('SerialDetailsComponent', () => {
password: 'pass',
});
const fetchSerialDetailsWithMetadata = jest.fn();
const cancelDetailsRequest = jest.fn();
const checkFavoriteStatus = jest.fn();
const constructEpisodeStreamUrl = jest.fn();
const addRecentItem = jest.fn();
@@ -142,6 +143,7 @@ describe('SerialDetailsComponent', () => {
isLoadingDetails.set(false);
detailsError.set(null);
fetchSerialDetailsWithMetadata.mockClear();
cancelDetailsRequest.mockClear();
checkFavoriteStatus.mockClear();
constructEpisodeStreamUrl.mockReset();
constructEpisodeStreamUrl.mockImplementation(
@@ -192,6 +194,7 @@ describe('SerialDetailsComponent', () => {
detailsError,
currentPlaylist,
fetchSerialDetailsWithMetadata,
cancelDetailsRequest,
checkFavoriteStatus,
setSelectedItem: jest.fn((value: unknown) =>
selectedItem.set(value)
@@ -347,6 +350,12 @@ describe('SerialDetailsComponent', () => {
});
});
it('invalidates an in-flight detail request on teardown', () => {
fixture.componentInstance.ngOnDestroy();
expect(cancelDetailsRequest).toHaveBeenCalledTimes(1);
});
it('renders series metadata when backdrop_path is absent at runtime', () => {
selectedItem.set({
series_id: 103,
@@ -302,6 +302,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.xtreamStore.cancelDetailsRequest();
this.playback.closeInlinePlayer();
this.xtreamStore.setSelectedItem(null);
}
@@ -0,0 +1,47 @@
import {
getXtreamVodInfo,
XtreamVodDetails,
} from '@iptvnator/shared/interfaces';
type XtreamVodPresentationItem = XtreamVodDetails & {
readonly name?: string;
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
};
export interface XtreamVodPlaybackPresentation {
readonly posterUrl?: string;
readonly title: string;
}
export function resolveXtreamVodPlaybackPresentation(
vodItem: XtreamVodDetails
): XtreamVodPlaybackPresentation {
const item = vodItem as XtreamVodPresentationItem;
const info = getXtreamVodInfo(vodItem);
return {
title:
firstText(
info?.name,
item.movie_data?.name,
item.title,
item.name
) ?? 'Unknown',
posterUrl: firstText(
info?.movie_image,
info?.cover_big,
item.poster_url,
item.stream_icon
),
};
}
function firstText(...values: unknown[]): string | undefined {
const value = values.find(
(candidate) =>
typeof candidate === 'string' && candidate.trim().length > 0
);
return typeof value === 'string' ? value.trim() : undefined;
}
@@ -15,7 +15,10 @@ import {
createLogger,
getPortalPlaybackProgressPercent,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
resolveXtreamVodPlaybackSource,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
PlaybackPositionData,
@@ -23,9 +26,9 @@ import {
ResolvedPortalPlayback,
XtreamVodDetails,
XtreamVodInfo,
getXtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
export interface VodDetailsPlaybackBindings {
/** Current vod id resolved from the route */
@@ -51,9 +54,7 @@ export class VodDetailsPlaybackService {
private readonly logger = createLogger('VodDetailsPlayback');
/** Signals bound from the host component via `bind()` */
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(
null
);
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
@@ -115,22 +116,26 @@ export class VodDetailsPlaybackService {
return;
}
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
const info = getXtreamVodInfo(vodItem);
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
this.addToRecentlyViewed();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = this.bindings()?.vodId();
const id =
routeVodId != null && Number.isFinite(routeVodId)
routeVodId != null &&
Number.isSafeInteger(routeVodId) &&
routeVodId > 0
? routeVodId
: Number(
vodItem.movie_data?.stream_id ||
(vodItem as { stream_id?: number }).stream_id
);
: source.streamId;
this.logger.debug('playVod resolved ID', { id, vodItem });
@@ -141,8 +146,8 @@ export class VodDetailsPlaybackService {
};
const playback: ResolvedPortalPlayback = {
streamUrl,
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
thumbnail: info?.movie_image,
title: presentation.title,
thumbnail: presentation.posterUrl,
contentInfo,
};
@@ -154,14 +159,25 @@ export class VodDetailsPlaybackService {
return;
}
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
const info = getXtreamVodInfo(vodItem);
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
this.addToRecentlyViewed();
const vodId = this.bindings()?.vodId() ?? NaN;
const routeVodId = this.bindings()?.vodId();
const vodId =
routeVodId != null &&
Number.isSafeInteger(routeVodId) &&
routeVodId > 0
? routeVodId
: source.streamId;
const position = this.vodPlaybackPosition();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
@@ -172,8 +188,8 @@ export class VodDetailsPlaybackService {
};
const playback: ResolvedPortalPlayback = {
streamUrl,
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
thumbnail: info?.movie_image,
title: presentation.title,
thumbnail: presentation.posterUrl,
startTime: position?.positionSeconds,
contentInfo,
};
@@ -0,0 +1,297 @@
import { Location } from '@angular/common';
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
CrossPortalSimilarService,
DownloadsService,
PlaybackPositionRuntimeBridgeService,
SettingsStore,
} from '@iptvnator/services';
import {
PlaybackPositionData,
XtreamVodDetails,
} from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { NEVER, of } from 'rxjs';
import { VodDetailsRouteComponent } from './vod-details-route.component';
@Component({
selector: 'app-portal-inline-player',
standalone: true,
template: '<div data-testid="inline-vod-player"></div>',
})
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly backClicked = output<void>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<unknown>();
}
type SparseVodItem = XtreamVodDetails & {
readonly container_extension: string;
readonly name: string;
readonly stream_icon: string;
readonly stream_id: number;
};
describe('VodDetailsRouteComponent fallback actions', () => {
let fixture: ComponentFixture<VodDetailsRouteComponent>;
const selectedItem = signal<XtreamVodDetails | null>(null);
const downloadsAvailable = signal(false);
const isFavorite = signal(false);
const downloads = signal([]);
const currentPlaylist = signal({
id: 'playlist-1',
userAgent: 'IPTVnator',
referrer: 'https://referrer.example',
origin: 'https://origin.example',
});
const constructVodStreamUrl = jest
.fn()
.mockReturnValue('http://example.com/movie/650020.mp4');
const isEmbeddedPlayer = jest.fn().mockReturnValue(true);
const openResolvedPlayback = jest.fn();
const startDownload = jest.fn().mockResolvedValue(undefined);
const toggleFavorite = jest.fn();
const sparseItem = (): SparseVodItem => ({
info: [],
stream_id: 650020,
container_extension: 'mp4',
name: 'Catalog movie',
stream_icon: 'https://example.com/catalog-poster.jpg',
});
beforeEach(async () => {
selectedItem.set(null);
downloadsAvailable.set(false);
isFavorite.set(false);
constructVodStreamUrl.mockClear();
isEmbeddedPlayer.mockReset().mockReturnValue(true);
openResolvedPlayback.mockClear();
startDownload.mockClear();
toggleFavorite.mockClear();
await TestBed.configureTestingModule({
imports: [VodDetailsRouteComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({
vodId: '650020',
categoryId: '235',
}),
snapshot: {
params: {
vodId: '650020',
categoryId: '235',
},
},
},
},
{
provide: Router,
useValue: { navigate: jest.fn() },
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: NEVER,
onTranslationChange: NEVER,
onDefaultLangChange: NEVER,
},
},
{
provide: XtreamStore,
useValue: {
selectedItem,
isLoadingDetails: signal(false),
detailsError: signal(null),
isFavorite,
currentPlaylist,
vodStreams: signal([]),
vodCategories: signal([]),
fetchVodDetailsWithMetadata: jest.fn(),
cancelDetailsRequest: jest.fn(),
checkFavoriteStatus: jest.fn(),
setSelectedItem: jest.fn(),
toggleFavorite,
constructVodStreamUrl,
addRecentItem: jest.fn(),
},
},
{
provide: DownloadsService,
useValue: {
isAvailable: downloadsAvailable,
downloads,
isDownloaded: jest.fn().mockReturnValue(false),
isDownloading: jest.fn().mockReturnValue(false),
isPaused: jest.fn().mockReturnValue(false),
resumeDownloadByContent: jest.fn(),
startDownload,
getDownloadedFilePath: jest.fn(),
playDownload: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer,
openResolvedPlayback,
openExternalPlayback: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
closeSession: jest.fn(),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn(),
},
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: jest.fn(),
},
},
{
provide: CrossPortalSimilarService,
useValue: {
isAvailable: false,
buildLink: jest.fn(),
matchRecommendations: jest.fn(),
},
},
{
provide: SettingsStore,
useValue: { theme: signal('dark') },
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{ provide: Location, useValue: { back: jest.fn() } },
],
})
.overrideComponent(VodDetailsRouteComponent, {
remove: { imports: [PortalInlinePlayerComponent] },
add: { imports: [StubPortalInlinePlayerComponent] },
})
.compileComponents();
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
it('starts playable sparse VOD inline with catalog presentation', () => {
const item = sparseItem();
selectedItem.set(item);
downloadsAvailable.set(true);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('button.play-btn')).not.toBeNull();
expect(host.querySelector('button.favorite-btn')).not.toBeNull();
expect(host.querySelector('button.download-btn')).not.toBeNull();
host.querySelector<HTMLButtonElement>('button.play-btn')?.click();
fixture.detectChanges();
expect(constructVodStreamUrl).toHaveBeenCalledWith(item);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
expect(inlinePlayer.playback()).toEqual(
expect.objectContaining({
streamUrl: 'http://example.com/movie/650020.mp4',
title: 'Catalog movie',
thumbnail: 'https://example.com/catalog-poster.jpg',
})
);
expect(
host.querySelector('app-portal-detail-shell')?.classList
).toContain('shell-host--watch');
});
it('reuses the sparse item for resume, favorite, and download', async () => {
const item = sparseItem();
selectedItem.set(item);
downloadsAvailable.set(true);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodPlaybackPosition.set({
contentXtreamId: 650020,
contentType: 'vod',
playlistId: 'playlist-1',
positionSeconds: 42,
durationSeconds: 120,
} satisfies PlaybackPositionData);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
host.querySelector<HTMLButtonElement>(
'button.play-btn--resume'
)?.click();
fixture.detectChanges();
host.querySelector<HTMLButtonElement>('button.favorite-btn')?.click();
host.querySelector<HTMLButtonElement>('button.download-btn')?.click();
await fixture.whenStable();
expect(constructVodStreamUrl).toHaveBeenNthCalledWith(1, item);
expect(constructVodStreamUrl).toHaveBeenNthCalledWith(2, item);
expect(toggleFavorite).toHaveBeenCalledWith(
'650020',
'playlist-1',
'movie',
undefined
);
expect(startDownload).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Catalog movie',
posterUrl: 'https://example.com/catalog-poster.jpg',
url: 'http://example.com/movie/650020.mp4',
})
);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
expect(inlinePlayer.playback()).toEqual(
expect.objectContaining({ startTime: 42 })
);
});
it('keeps external playback in the browse shell', () => {
const item = sparseItem();
selectedItem.set(item);
isEmbeddedPlayer.mockReturnValue(false);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
host.querySelector<HTMLButtonElement>('button.play-btn')?.click();
fixture.detectChanges();
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Catalog movie',
thumbnail: 'https://example.com/catalog-poster.jpg',
}),
true
);
expect(
fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
)
).toBeNull();
expect(
host.querySelector('app-portal-detail-shell')?.classList
).not.toContain('shell-host--watch');
});
});
@@ -1,3 +1,132 @@
<ng-template #vodActions>
@if (playableVodItem(); as playableItem) {
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
(click)="onPrimaryAction(playableItem)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.RESUME' | translate }}
{{ formatPosition() }}
}
</span>
</button>
@if (!isExternalStopAction()) {
<button
class="play-btn play-btn--restart"
(click)="playVod(playableItem)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
}
} @else {
<button
class="play-btn"
(click)="onPrimaryAction(playableItem)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.PLAY' | translate }}
}
</span>
</button>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron()) {
@if (isDownloaded()) {
<button
class="download-btn download-btn--completed"
(click)="playFromLocal()"
>
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
(click)="resumePausedDownload()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'DOWNLOADS.RESUME' | translate }}
</button>
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button
class="download-btn"
(click)="downloadVod(playableItem)"
>
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
}
</ng-template>
<ng-template #inlinePlayer>
@if (playableVodItem()) {
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
(streamUrlCopied)="showCopyNotification()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
/>
}
}
</ng-template>
@let item = selectedItem();
@if (selectedVodInfo(); as info) {
<app-portal-detail-shell
@@ -37,7 +166,12 @@
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
@if (info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
@for (
member of info.tmdb_cast;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -81,10 +215,17 @@
}
@if (info.director || info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
@if (info.tmdb_directors?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
@for (
member of info.tmdb_directors;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -129,128 +270,9 @@
</ng-template>
<ng-template appDetailActions>
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
(click)="onPrimaryAction(item)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.RESUME' | translate }}
{{ formatPosition() }}
}
</span>
</button>
@if (!isExternalStopAction()) {
<button
class="play-btn play-btn--restart"
(click)="playVod(item)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
}
} @else {
<button
class="play-btn"
(click)="onPrimaryAction(item)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.PLAY' | translate }}
}
</span>
</button>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron()) {
@if (isDownloaded()) {
<button
class="download-btn download-btn--completed"
(click)="playFromLocal()"
>
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
(click)="resumePausedDownload()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'DOWNLOADS.RESUME' | translate }}
</button>
} @else if (isDownloading()) {
<button
class="download-btn download-btn--downloading"
disabled
>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="downloadVod(item)">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
<ng-container [ngTemplateOutlet]="vodActions" />
</ng-template>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
(streamUrlCopied)="showCopyNotification()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
/>
}
<ng-container detail-player [ngTemplateOutlet]="inlinePlayer" />
<div detail-extras>
@if (trailerEmbedUrl(); as trailerUrl) {
@@ -308,15 +330,16 @@
}
@for (
item of similarInPortals();
track 'x' + item.match.playlistId + '-' + item.match.xtreamId
track 'x' +
item.match.playlistId +
'-' +
item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
item.title + ' — ' + item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@@ -351,7 +374,9 @@
<app-portal-detail-shell
[title]="fallback.title"
[posterUrl]="fallback.posterUrl"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
>
<ng-template appDetailTags>
<span
@@ -393,6 +418,11 @@
</div>
}
</ng-template>
<ng-template appDetailActions>
<ng-container [ngTemplateOutlet]="vodActions" />
</ng-template>
<ng-container detail-player [ngTemplateOutlet]="inlinePlayer" />
</app-portal-detail-shell>
} @else if (detailsError()) {
<app-portal-detail-shell
@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { NEVER, of } from 'rxjs';
import { Location } from '@angular/common';
import { ContentHeroComponent } from '@iptvnator/ui/components';
import {
@@ -38,6 +38,7 @@ describe('VodDetailsRouteComponent', () => {
const vodStreams = signal<Partial<XtreamVodStream>[]>([]);
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
const fetchVodDetailsWithMetadata = jest.fn();
const cancelDetailsRequest = jest.fn();
const checkFavoriteStatus = jest.fn();
const setSelectedItem = jest.fn();
const toggleFavorite = jest.fn();
@@ -84,6 +85,7 @@ describe('VodDetailsRouteComponent', () => {
vodStreams.set([]);
vodCategories.set([]);
fetchVodDetailsWithMetadata.mockClear();
cancelDetailsRequest.mockClear();
checkFavoriteStatus.mockClear();
setSelectedItem.mockClear();
toggleFavorite.mockClear();
@@ -115,9 +117,9 @@ describe('VodDetailsRouteComponent', () => {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
onLangChange: NEVER,
onTranslationChange: NEVER,
onDefaultLangChange: NEVER,
currentLang: 'en',
defaultLang: 'en',
},
@@ -133,6 +135,7 @@ describe('VodDetailsRouteComponent', () => {
vodStreams,
vodCategories,
fetchVodDetailsWithMetadata,
cancelDetailsRequest,
checkFavoriteStatus,
setSelectedItem,
toggleFavorite,
@@ -204,10 +207,19 @@ describe('VodDetailsRouteComponent', () => {
consoleWarnSpy?.mockRestore();
});
it('renders an informational fallback without playback controls when Xtream returns empty metadata', () => {
it('keeps the fallback visible and exposes actions when catalog playback fields are usable', () => {
selectedItem.set({
info: [],
} as XtreamVodDetails);
stream_id: 650020,
name: 'Die Kühe sind Los! (2004) DE',
stream_icon: 'https://example.com/cows.jpg',
container_extension: 'mp4',
} as XtreamVodDetails & {
container_extension: string;
name: string;
stream_icon: string;
stream_id: number;
});
vodStreams.set([
{
name: 'Die Kühe sind Los! (2004) DE',
@@ -239,6 +251,46 @@ describe('VodDetailsRouteComponent', () => {
host.querySelector('[data-testid="xtream-vod-fallback-status"]')
?.textContent
).toContain('XTREAM.DETAIL_FALLBACK.STATUS');
expect(host.querySelector('button.play-btn')).not.toBeNull();
expect(host.querySelector('button.favorite-btn')).not.toBeNull();
expect(host.querySelector('button.download-btn')).toBeNull();
});
it('keeps the fallback visible for a minimal info object', () => {
selectedItem.set({
info: {
name: 'Only a provider title',
},
stream_id: 650020,
name: 'Catalog title',
container_extension: 'mp4',
} as unknown as XtreamVodDetails);
vodStreams.set([
{
name: 'Catalog title',
stream_id: 650020,
container_extension: 'mp4',
},
]);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(
host.querySelector('[data-testid="xtream-vod-fallback"]')
).not.toBeNull();
expect(host.querySelector('button.play-btn')).not.toBeNull();
});
it('hides actions when the fallback item has no usable playback source', () => {
selectedItem.set({ info: [] });
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(
host.querySelector('[data-testid="xtream-vod-fallback"]')
).not.toBeNull();
expect(host.querySelector('button.play-btn')).toBeNull();
expect(host.querySelector('button.favorite-btn')).toBeNull();
expect(host.querySelector('button.download-btn')).toBeNull();
@@ -322,4 +374,10 @@ describe('VodDetailsRouteComponent', () => {
).componentInstance as ContentHeroComponent;
expect(hero.backdropUrl()).toBeUndefined();
});
it('invalidates an in-flight detail request on teardown', () => {
fixture.componentInstance.ngOnDestroy();
expect(cancelDetailsRequest).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { Location, SlicePipe } from '@angular/common';
import { Location, NgTemplateOutlet, SlicePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -23,7 +23,10 @@ import {
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
resolveXtreamVodPlaybackSource,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
@@ -53,6 +56,7 @@ import {
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
@Component({
templateUrl: './vod-details-route.component.html',
@@ -67,6 +71,7 @@ import { VodDetailsPlaybackService } from './vod-details-playback.service';
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MatIcon,
NgTemplateOutlet,
PortalDetailShellComponent,
SafePipe,
SlicePipe,
@@ -178,6 +183,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
? getXtreamVodInfo(item)
: null;
});
readonly playableVodItem = computed(() => {
const item = this.selectedItem();
return item && resolveXtreamVodPlaybackSource(item) ? item : null;
});
readonly fallbackView = computed(() => {
const item = this.selectedItem();
if (!item || this.selectedVodInfo()) {
@@ -367,6 +376,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.xtreamStore.cancelDetailsRequest();
this.playback.closeInlinePlayer();
this.xtreamStore.setSelectedItem(null);
}
@@ -456,15 +466,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
return;
}
const info = getXtreamVodInfo(vodItem);
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
}
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = this.route.snapshot.params.vodId;
const id = routeVodId
? Number(routeVodId)
: Number(
vodItem.movie_data?.stream_id ||
(vodItem as { stream_id?: number }).stream_id
);
const routeVodId = Number(this.route.snapshot.params.vodId);
const id =
Number.isSafeInteger(routeVodId) && routeVodId > 0
? routeVodId
: source.streamId;
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
@@ -475,9 +488,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
playlistId: playlist.id,
xtreamId: id,
contentType: 'vod',
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
title: presentation.title,
url: streamUrl,
posterUrl: info?.movie_image,
posterUrl: presentation.posterUrl,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
@@ -26,6 +26,7 @@ describe('XtreamCollectionDetailComponent', () => {
let selectedItem: ReturnType<typeof signal<unknown>>;
let isLoadingDetails: ReturnType<typeof signal<boolean>>;
let detailsError: ReturnType<typeof signal<string | null>>;
let cancelDetailsRequest: jest.Mock;
beforeEach(async () => {
playlistId = signal('');
@@ -35,6 +36,7 @@ describe('XtreamCollectionDetailComponent', () => {
selectedItem = signal<unknown>(null);
isLoadingDetails = signal(false);
detailsError = signal<string | null>(null);
cancelDetailsRequest = jest.fn();
await TestBed.configureTestingModule({
imports: [XtreamCollectionDetailComponent],
@@ -49,6 +51,7 @@ describe('XtreamCollectionDetailComponent', () => {
selectedItem,
isLoadingDetails,
detailsError,
cancelDetailsRequest,
setPlaylistId: jest.fn((value: string) =>
playlistId.set(value)
),
@@ -158,4 +161,10 @@ describe('XtreamCollectionDetailComponent', () => {
?.get(XTREAM_SERIES_RESUME_TARGET)()
).toEqual(seriesResume);
});
it('invalidates detail loading before restoring the underlying store', () => {
fixture.componentInstance.ngOnDestroy();
expect(cancelDetailsRequest).toHaveBeenCalledTimes(1);
});
});
@@ -87,6 +87,7 @@ export class XtreamCollectionDetailComponent {
}
ngOnDestroy(): void {
this.xtreamStore.cancelDetailsRequest();
this.restoreStoreState();
}