fix(xtream): render sparse VOD before recovery

This commit is contained in:
4gray committed 2026-07-29 00:41:47 +02:00
1 parent 221b1f82c6
commit c7cafee01e
7 files changed
+457 -43

No files matched your search

+1 -1
View File
@@ -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
@@ -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
@@ -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<XtreamVodStream | null>((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,
});
});
});
@@ -71,10 +71,43 @@ interface ResolveXtreamVodDetailsSelectionOptions extends Omit<
}
export interface ResolveXtreamVodDetailsSelectionResult {
readonly recovery: Promise<XtreamVodDetailsRecoveryResult> | 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<ResolveXtreamVodDetailsSelectionResult> {
}: 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,
};
}
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, reject, resolve };
}
async function waitForCondition(
predicate: () => boolean,
attempts = 20
): Promise<void> {
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<XtreamVodStream | null>();
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<XtreamVodStream | null>();
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<XtreamVodStream | null>();
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);
});
});
@@ -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;
@@ -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', () => {