mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -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
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
---
|
||||
|
||||
Xtream movies remain playable when a portal omits extended VOD metadata,
|
||||
using catalog stream details to build the playback URL.
|
||||
@@ -861,6 +861,7 @@ engine` (restart required) or
|
||||
- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style)
|
||||
- For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill
|
||||
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
|
||||
- Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the merged catalog fields. When Electron's normalized catalog cache lacks the extension, the detail loader maps the SQLite route category through all persisted categories, including hidden ones, and makes one category-scoped raw catalog lookup for the exact VOD; PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields.
|
||||
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
|
||||
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again.
|
||||
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
|
||||
|
||||
@@ -25,6 +25,7 @@ export type XtreamLiveStream = {
|
||||
export type XtreamVodStream = {
|
||||
added?: string;
|
||||
category_id?: string;
|
||||
container_extension?: string;
|
||||
name?: string;
|
||||
stream_id?: number | string;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamVodFixture,
|
||||
@@ -21,7 +22,7 @@ const emptyMetadataCredentials = {
|
||||
};
|
||||
|
||||
test.describe('Xtream VOD Details', () => {
|
||||
test('shows a curated fallback when the portal returns empty VOD metadata', async ({
|
||||
test('keeps a sparse VOD playable inside the curated fallback', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
@@ -30,7 +31,29 @@ test.describe('Xtream VOD Details', () => {
|
||||
request,
|
||||
emptyMetadataCredentials
|
||||
);
|
||||
const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle);
|
||||
const [movieTitle] = pickDistinctTitles(
|
||||
vodFixture.items,
|
||||
getXtreamTitle
|
||||
);
|
||||
const movieItem = vodFixture.items.find(
|
||||
(item) => getXtreamTitle(item) === movieTitle
|
||||
);
|
||||
const streamId = Number(movieItem?.stream_id);
|
||||
const containerExtension = movieItem?.container_extension?.trim();
|
||||
if (
|
||||
!Number.isInteger(streamId) ||
|
||||
streamId <= 0 ||
|
||||
!containerExtension
|
||||
) {
|
||||
throw new Error(
|
||||
'Xtream VOD fixture returned an invalid playback source.'
|
||||
);
|
||||
}
|
||||
const expectedMovieUrl =
|
||||
`${xtreamMockServer}/movie/` +
|
||||
`${emptyMetadataCredentials.username}/` +
|
||||
`${emptyMetadataCredentials.password}/` +
|
||||
`${streamId}.${containerExtension}`;
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
@@ -43,17 +66,22 @@ test.describe('Xtream VOD Details', () => {
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(app.mainWindow, vodFixture.categoryName);
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
await clickGridListCardByTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
|
||||
);
|
||||
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
|
||||
movieTitle
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('[data-testid="xtream-vod-fallback-status"]')
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toContainText(movieTitle);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'[data-testid="xtream-vod-fallback-status"]'
|
||||
)
|
||||
).toContainText('Portal metadata unavailable');
|
||||
await expect(
|
||||
app.mainWindow.locator('[data-testid="xtream-vod-fallback"]')
|
||||
@@ -61,13 +89,40 @@ test.describe('Xtream VOD Details', () => {
|
||||
'Extended metadata was not provided by this portal.'
|
||||
);
|
||||
|
||||
await expect(app.mainWindow.locator('button.play-btn')).toHaveCount(0);
|
||||
await expect(app.mainWindow.locator('button.favorite-btn')).toHaveCount(
|
||||
0
|
||||
);
|
||||
await expect(app.mainWindow.locator('button.download-btn')).toHaveCount(
|
||||
0
|
||||
const playButton = app.mainWindow
|
||||
.locator('button.play-btn')
|
||||
.first();
|
||||
await expect(playButton).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.locator('button.favorite-btn').first()
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.locator('button.download-btn').first()
|
||||
).toBeVisible();
|
||||
|
||||
const movieResponsePromise = app.mainWindow.waitForResponse(
|
||||
(response) =>
|
||||
response.url() === expectedMovieUrl &&
|
||||
response.request().method() === 'GET',
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
await playButton.click();
|
||||
|
||||
const movieResponse = await movieResponsePromise;
|
||||
expect(movieResponse.status()).toBe(302);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-portal-detail-shell')
|
||||
).toHaveClass(/shell-host--watch/);
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator('app-portal-inline-player app-web-player-view')
|
||||
.first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'app-portal-inline-player .player-shell__title'
|
||||
)
|
||||
).toContainText(movieTitle);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,34 @@ If stored Xtream playback credentials contain an invalid server URL or blank
|
||||
username/password, stream URL construction returns an empty URL instead of
|
||||
throwing during playback.
|
||||
|
||||
VOD playback uses the standard
|
||||
`/movie/{username}/{password}/{streamId}.{containerExtension}` URL after
|
||||
resolving the stream id and container extension as one source. Fields from
|
||||
`movie_data` take priority, with the top-level catalog fields used as a
|
||||
fallback when extended metadata is absent or incomplete. A source requires a
|
||||
positive integer id and a non-empty container extension; otherwise URL
|
||||
construction returns an empty string.
|
||||
|
||||
The Electron catalog cache does not persist the container extension. If the
|
||||
merged detail response and cached row still cannot resolve a source, the detail
|
||||
loader requests the exact VOD from its provider category and merges that raw
|
||||
catalog row in memory. An Electron route category uses the SQLite row id, so
|
||||
the loader maps it through the complete persisted category set, including
|
||||
hidden categories, before sending the provider `category_id`; PWA falls back
|
||||
to its API-backed visible categories, and an unresolved database id is never
|
||||
sent as though it were a provider id. This recovery request is skipped when
|
||||
`movie_data` or the cached catalog fields are already sufficient, and a failed
|
||||
lookup leaves the item safely unplayable. Detail requests are generation- and
|
||||
playlist-guarded, and detail teardown invalidates the active generation, so a
|
||||
late response cannot replace a newer selection or repopulate a closed detail.
|
||||
|
||||
Metadata availability and VOD playability are independent. An empty or sparse
|
||||
`get_vod_info` response keeps the curated fallback detail page, but that page
|
||||
offers the same Play/Resume, Favorite, and Download actions when the source
|
||||
resolves. An unresolved source exposes no actions. Playback and download
|
||||
descriptors also fall back to the catalog title and poster after `info` and
|
||||
`movie_data`.
|
||||
|
||||
## Catch-Up Playback URLs
|
||||
|
||||
Xtream-compatible portals differ on archive playback URL shape. IPTVnator
|
||||
|
||||
@@ -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