From c7cafee01e5579e90c18731b701bcd111b49e9fb Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Jul 2026 00:41:47 +0200 Subject: [PATCH] fix(xtream): render sparse VOD before recovery --- CLAUDE.md | 2 +- .../xtream-portal-compatibility.md | 15 +- .../lib/stores/xtream-details-request.spec.ts | 99 ++++++- .../src/lib/stores/xtream-details-request.ts | 73 ++++-- .../src/lib/stores/xtream.store.spec.ts | 243 ++++++++++++++++++ .../src/lib/stores/xtream.store.ts | 46 +++- .../vod-details-route.component.spec.ts | 22 +- 7 files changed, 457 insertions(+), 43 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index db9826092..84fd887ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -861,7 +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 catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. 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. +- 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 catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the SQLite route category through all persisted categories, including hidden ones; 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, preserves concurrent metadata enrichment, 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 diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index d858f0188..7821773fa 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -108,12 +108,15 @@ 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 the detail response or owner-valid cached catalog fields are already -sufficient. Detail, recovered, and cached playback fields remain separate -candidates: the first complete pair wins, so two incomplete rows can never -synthesize a source. 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. +sufficient. Recovery is best-effort and never gates the detail page: the +initial sparse selection is published and the loading shell ends before the +category lookup completes, then a successful result upgrades that same +fallback reactively with playback actions. Detail, recovered, and cached +playback fields remain separate candidates: the first complete pair wins, so +two incomplete rows can never synthesize a source. A failed lookup leaves the +already-rendered 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. In-memory VOD category and stream arrays record the playlist that populated them. Cross-portal Favorites/Recent details ignore arrays owned by another playlist, so colliding Xtream ids cannot suppress recovery or contribute a diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.spec.ts index f14448509..cbdab6505 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.spec.ts @@ -128,7 +128,7 @@ describe('Xtream details request coordination', () => { stream_id: 42, container_extension: 'mkv', }); - const result = await resolveXtreamVodDetailsSelection({ + const resolution = resolveXtreamVodDetailsSelection({ apiService: { getVodStream }, currentCategories: [{ id: 7, xtream_id: 999 }], currentCategoriesPlaylistId: 'playlist-a', @@ -149,6 +149,13 @@ describe('Xtream details request coordination', () => { vodId: 42, }); + expect(resolveXtreamVodPlaybackSource(resolution.selection)).toBeNull(); + expect(resolution.recovery).not.toBeNull(); + if (!resolution.recovery) { + throw new Error('Expected catalog recovery to start'); + } + const result = await resolution.recovery; + expect(resolveXtreamVodPlaybackSource(result.selection)).toEqual({ streamId: 42, containerExtension: 'mkv', @@ -158,11 +165,11 @@ describe('Xtream details request coordination', () => { expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701); }); - it('uses a catalog pair owned by the active playlist without recovery', async () => { + it('uses a catalog pair owned by the active playlist without recovery', () => { const getAllCategories = jest.fn(); const getCategories = jest.fn(); const getVodStream = jest.fn(); - const result = await resolveXtreamVodDetailsSelection({ + const result = resolveXtreamVodDetailsSelection({ apiService: { getVodStream }, currentCategories: [], currentCategoriesPlaylistId: 'playlist-b', @@ -187,8 +194,94 @@ describe('Xtream details request coordination', () => { streamId: 42, containerExtension: 'mp4', }); + expect(result.recovery).toBeNull(); expect(getAllCategories).not.toHaveBeenCalled(); expect(getCategories).not.toHaveBeenCalled(); expect(getVodStream).not.toHaveBeenCalled(); }); + + it('returns sparse details before catalog recovery completes', async () => { + let resolveCatalogItem!: (item: XtreamVodStream | null) => void; + const getVodStream = jest.fn( + () => + new Promise((resolve) => { + resolveCatalogItem = resolve; + }) + ); + const resolution = resolveXtreamVodDetailsSelection({ + apiService: { getVodStream }, + currentCategories: [{ id: 7, xtream_id: 701 }], + currentCategoriesPlaylistId: 'playlist-b', + currentStreams: [], + currentStreamsPlaylistId: 'playlist-b', + credentials, + dataSource: { + getAllCategories: jest.fn(), + getCategories: jest.fn(), + }, + isCurrent: () => true, + playlistId: 'playlist-b', + routeCategoryId: 7, + vodDetails: { info: [] }, + vodId: 42, + }); + + expect(resolution).not.toBeInstanceOf(Promise); + expect(resolveXtreamVodPlaybackSource(resolution.selection)).toBeNull(); + expect(resolution.recovery).not.toBeNull(); + if (!resolution.recovery) { + return; + } + + let recoverySettled = false; + void resolution.recovery.finally(() => { + recoverySettled = true; + }); + await Promise.resolve(); + expect(recoverySettled).toBe(false); + + resolveCatalogItem({ + stream_id: 42, + container_extension: 'mkv', + } as XtreamVodStream); + + await expect(resolution.recovery).resolves.toEqual( + expect.objectContaining({ + selection: expect.objectContaining({ + stream_id: 42, + container_extension: 'mkv', + }), + }) + ); + }); + + it('contains catalog recovery errors without rejecting the fallback', async () => { + const recoveryError = new Error('Catalog unavailable'); + const resolution = resolveXtreamVodDetailsSelection({ + apiService: { + getVodStream: jest.fn().mockRejectedValue(recoveryError), + }, + currentCategories: [{ id: 7, xtream_id: 701 }], + currentCategoriesPlaylistId: 'playlist-b', + currentStreams: [], + currentStreamsPlaylistId: 'playlist-b', + credentials, + dataSource: { + getAllCategories: jest.fn(), + getCategories: jest.fn(), + }, + isCurrent: () => true, + playlistId: 'playlist-b', + routeCategoryId: 7, + vodDetails: { info: [] }, + vodId: 42, + }); + + expect(resolution.recovery).not.toBeNull(); + await expect(resolution.recovery).resolves.toEqual({ + recoveredCatalogItem: null, + recoveryError, + selection: resolution.selection, + }); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.ts index e72fe0b4c..099f836f0 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.ts @@ -71,10 +71,43 @@ interface ResolveXtreamVodDetailsSelectionOptions extends Omit< } export interface ResolveXtreamVodDetailsSelectionResult { + readonly recovery: Promise | null; + readonly selection: XtreamVodSelection; +} + +export interface XtreamVodDetailsRecoveryResult { + readonly recoveredCatalogItem: XtreamVodStream | null; readonly recoveryError?: unknown; readonly selection: XtreamVodSelection; } +export function applyRecoveredXtreamVodCatalogItem( + currentSelection: { + readonly [key: string]: unknown; + readonly stream_id?: string | number; + readonly xtream_id?: number; + } | null, + recoveredCatalogItem: XtreamVodStream | null, + vodId: string | number +): XtreamVodSelection | null { + const currentVodId = + currentSelection?.xtream_id ?? currentSelection?.stream_id; + if ( + !currentSelection || + !recoveredCatalogItem || + Number(currentVodId) !== Number(vodId) + ) { + return null; + } + + return buildXtreamVodSelection( + currentSelection as XtreamVodDetails, + undefined, + vodId, + recoveredCatalogItem + ); +} + export async function recoverXtreamVodCatalogItem({ apiService, currentCategories, @@ -134,14 +167,14 @@ export async function recoverXtreamVodCatalogItem({ return isCurrent() ? item : null; } -export async function resolveXtreamVodDetailsSelection({ +export function resolveXtreamVodDetailsSelection({ currentCategories, currentCategoriesPlaylistId, currentStreams, currentStreamsPlaylistId, vodDetails, ...recoveryOptions -}: ResolveXtreamVodDetailsSelectionOptions): Promise { +}: ResolveXtreamVodDetailsSelectionOptions): ResolveXtreamVodDetailsSelectionResult { const catalogItem = currentStreamsPlaylistId === recoveryOptions.playlistId ? findXtreamVodCatalogItem(currentStreams, recoveryOptions.vodId) @@ -153,19 +186,18 @@ export async function resolveXtreamVodDetailsSelection({ ); if (resolveXtreamVodPlaybackSource(selection)) { - return { selection }; + return { recovery: null, selection }; } - try { - const recoveredCatalogItem = await recoverXtreamVodCatalogItem({ - ...recoveryOptions, - currentCategories: - currentCategoriesPlaylistId === recoveryOptions.playlistId - ? currentCategories - : [], - }); - - return { + const recovery = recoverXtreamVodCatalogItem({ + ...recoveryOptions, + currentCategories: + currentCategoriesPlaylistId === recoveryOptions.playlistId + ? currentCategories + : [], + }) + .then((recoveredCatalogItem) => ({ + recoveredCatalogItem, selection: recoveredCatalogItem ? buildXtreamVodSelection( vodDetails, @@ -174,8 +206,15 @@ export async function resolveXtreamVodDetailsSelection({ recoveredCatalogItem ) : selection, - }; - } catch (recoveryError) { - return { recoveryError, selection }; - } + })) + .catch((recoveryError: unknown) => ({ + recoveredCatalogItem: null, + recoveryError, + selection, + })); + + return { + recovery, + selection, + }; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts new file mode 100644 index 000000000..7e2b8bba7 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts @@ -0,0 +1,243 @@ +import { TestBed } from '@angular/core/testing'; +import { + DatabaseService, + PlaybackPositionRuntimeBridgeService, + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, + TmdbEnrichmentService, + XtreamPendingRestoreService, +} from '@iptvnator/services'; +import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; +import { XtreamVodStream } from '@iptvnator/shared/interfaces'; +import { + XTREAM_DATA_SOURCE, + XtreamPlaylistData, +} from '../data-sources/xtream-data-source.interface'; +import { XtreamApiService } from '../services/xtream-api.service'; +import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { XtreamUrlService } from '../services/xtream-url.service'; +import { XtreamXmltvFallbackService } from '../services/xtream-xmltv-fallback.service'; +import { XtreamStore } from './xtream.store'; + +const PLAYLIST: XtreamPlaylistData = { + id: 'playlist-b', + name: 'Portal B', + serverUrl: 'http://portal-b.example', + username: 'demo', + password: 'secret', + type: 'xtream', +}; + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, reject, resolve }; +} + +async function waitForCondition( + predicate: () => boolean, + attempts = 20 +): Promise { + for (let index = 0; index < attempts; index += 1) { + if (predicate()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + throw new Error('Timed out waiting for test condition'); +} + +function configureStore( + xtreamApiService: { + getVodInfo: jest.Mock; + getVodStream: jest.Mock; + }, + dataSource = { + getAllCategories: jest + .fn() + .mockResolvedValue([{ id: 7, xtream_id: 701 }]), + getCategories: jest.fn().mockResolvedValue([]), + } +) { + TestBed.configureTestingModule({ + providers: [ + XtreamStore, + { provide: XTREAM_DATA_SOURCE, useValue: dataSource }, + { provide: XtreamApiService, useValue: xtreamApiService }, + { provide: DatabaseService, useValue: {} }, + { provide: PlaylistsService, useValue: {} }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: jest.fn(() => jest.fn()), + }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsEpg: false }, + }, + { + provide: SettingsStore, + useValue: { + preferUploadedEpgOverXtream: jest.fn(() => false), + }, + }, + { + provide: TmdbEnrichmentService, + useValue: { isEnabled: jest.fn(() => false) }, + }, + { provide: XtreamPendingRestoreService, useValue: {} }, + { provide: XtreamUrlService, useValue: {} }, + { provide: XtreamXmltvFallbackService, useValue: {} }, + { provide: PORTAL_PLAYER, useValue: {} }, + ], + }); + + const store = TestBed.inject(XtreamStore); + store.setCurrentPlaylist(PLAYLIST); + return store; +} + +describe('XtreamStore VOD details recovery', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('publishes sparse details before upgrading them from the catalog', async () => { + const catalogItem = createDeferred(); + const xtreamApiService = { + getVodInfo: jest.fn().mockResolvedValue({ info: [] }), + getVodStream: jest.fn(() => catalogItem.promise), + }; + const store = configureStore(xtreamApiService); + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '42', + }); + + await waitForCondition( + () => xtreamApiService.getVodStream.mock.calls.length === 1 + ); + + expect(store.selectedItem()).toEqual( + expect.objectContaining({ + info: [], + stream_id: 42, + container_extension: null, + }) + ); + expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + + const publishedSelection = store.selectedItem(); + if (!publishedSelection) { + throw new Error('Expected the sparse selection to be published'); + } + store.setSelectedItem({ + ...publishedSelection, + info: { name: 'Enriched while recovery was pending' }, + }); + catalogItem.resolve({ + stream_id: 42, + container_extension: 'mkv', + } as XtreamVodStream); + await waitForCondition( + () => + resolveXtreamVodPlaybackSource(store.selectedItem()) + ?.containerExtension === 'mkv' + ); + + expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + streamId: 42, + containerExtension: 'mkv', + }); + expect(store.selectedItem()?.info).toEqual({ + name: 'Enriched while recovery was pending', + }); + }); + + it('drops a catalog upgrade after a newer detail request starts', async () => { + const firstCatalogItem = createDeferred(); + const xtreamApiService = { + getVodInfo: jest.fn((_credentials: unknown, vodId: string) => + Promise.resolve( + vodId === '42' + ? { info: [] } + : { + info: [], + stream_id: 99, + container_extension: 'mp4', + } + ) + ), + getVodStream: jest.fn(() => firstCatalogItem.promise), + }; + const store = configureStore(xtreamApiService); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '42', + }); + await waitForCondition( + () => xtreamApiService.getVodStream.mock.calls.length === 1 + ); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '99', + }); + await waitForCondition( + () => + resolveXtreamVodPlaybackSource(store.selectedItem()) + ?.streamId === 99 + ); + + firstCatalogItem.resolve({ + stream_id: 42, + container_extension: 'mkv', + } as XtreamVodStream); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + streamId: 99, + containerExtension: 'mp4', + }); + }); + + it('keeps the fallback usable as a page when catalog recovery fails', async () => { + const catalogItem = createDeferred(); + const xtreamApiService = { + getVodInfo: jest.fn().mockResolvedValue({ info: [] }), + getVodStream: jest.fn(() => catalogItem.promise), + }; + const store = configureStore(xtreamApiService); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '42', + }); + await waitForCondition( + () => xtreamApiService.getVodStream.mock.calls.length === 1 + ); + catalogItem.reject(new Error('Catalog unavailable')); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(store.selectedItem()).toEqual( + expect.objectContaining({ + info: [], + stream_id: 42, + container_extension: null, + }) + ); + expect(store.detailsError()).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index e75bf0f43..7a807cc3e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -28,6 +28,7 @@ import { enrichVodSelectionWithTmdb, } from './xtream-tmdb-enrichment'; import { + applyRecoveredXtreamVodCatalogItem, createXtreamDetailsRequestGuard, resolveXtreamVodDetailsSelection, } from './xtream-details-request'; @@ -144,11 +145,11 @@ export const XtreamStore = signalStore( store.setDetailsError(null); xtreamApiService .getVodInfo(credentials, params.vodId) - .then(async (vodDetails: XtreamVodDetails) => { + .then((vodDetails: XtreamVodDetails) => { if (!isCurrentRequest()) return; - const { recoveryError, selection } = - await resolveXtreamVodDetailsSelection({ + const { recovery, selection } = + resolveXtreamVodDetailsSelection({ apiService: xtreamApiService, currentCategories: store.vodCategories(), currentCategoriesPlaylistId: @@ -164,13 +165,6 @@ export const XtreamStore = signalStore( vodDetails, vodId: params.vodId, }); - if (recoveryError && isCurrentRequest()) { - logger.warn( - 'Failed to recover sparse VOD playback source from the catalog', - recoveryError - ); - } - if (!isCurrentRequest()) return; store.setSelectedCategory(params.categoryId); store.setSelectedItem(selection); @@ -179,6 +173,38 @@ export const XtreamStore = signalStore( tmdbEnrichment, params.vodId ); + + if (!recovery) { + return; + } + + void recovery.then( + ({ recoveredCatalogItem, recoveryError }) => { + if (!isCurrentRequest()) { + return; + } + if (recoveryError) { + logger.warn( + 'Failed to recover sparse VOD playback source from the catalog', + recoveryError + ); + return; + } + if (!recoveredCatalogItem) { + return; + } + + const recoveredSelection = + applyRecoveredXtreamVodCatalogItem( + store.selectedItem(), + recoveredCatalogItem, + params.vodId + ); + if (recoveredSelection) { + store.setSelectedItem(recoveredSelection); + } + } + ); }) .catch((error: unknown) => { if (!isCurrentRequest()) return; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts index 63014fd30..b2c4723de 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts @@ -288,18 +288,28 @@ describe('VodDetailsRouteComponent', () => { expect(host.querySelector('button.play-btn')).not.toBeNull(); }); - it('hides actions when the fallback item has no usable playback source', () => { - selectedItem.set({ info: [] }); + it('reveals fallback actions when async catalog recovery adds a source', () => { + selectedItem.set({ + info: [], + stream_id: 650020, + container_extension: null, + } as unknown as XtreamVodDetails); 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(); + + selectedItem.set({ + info: [], + stream_id: 650020, + container_extension: 'mkv', + } as unknown as XtreamVodDetails); + fixture.detectChanges(); + + expect(host.querySelector('button.play-btn')).not.toBeNull(); + expect(host.querySelector('button.favorite-btn')).not.toBeNull(); }); it('keeps the full Xtream detail view when usable metadata exists', () => {