mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
fix(xtream): keep sparse VOD details playable
This commit is contained in:
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user