diff --git a/.changes/xtream-sparse-vod-playback.md b/.changes/xtream-sparse-vod-playback.md new file mode 100644 index 000000000..dd9239630 --- /dev/null +++ b/.changes/xtream-sparse-vod-playback.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index f37f92450..4f82f2bfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -883,6 +883,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 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 normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. 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/apps/electron-backend-e2e/src/portal-mock-fixtures.ts b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts index cf53230db..043976e1b 100644 --- a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts +++ b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts @@ -25,6 +25,7 @@ export type XtreamLiveStream = { export type XtreamVodStream = { added?: string; category_id?: string; + container_extension?: string; name?: string; stream_id?: number | string; }; diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index 43c07c7b5..b1565689c 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -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); } diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 1f90dbc2b..74f57c1e4 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -91,6 +91,48 @@ 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`. Cross-portal +Similar links already carry that provider id, so recovery accepts either the +SQLite `id` or `xtream_id` representation while preserving local-id lookup +precedence. If the numeric representations collide, candidate provider ids are +deduplicated and tried in that order until the exact VOD is found. 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. 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 +foreign playback extension, title, poster, category, or recommendation. + +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 diff --git a/libs/portal/xtream/data-access/src/lib/services/index.ts b/libs/portal/xtream/data-access/src/lib/services/index.ts index 69f0513b9..9b486e314 100644 --- a/libs/portal/xtream/data-access/src/lib/services/index.ts +++ b/libs/portal/xtream/data-access/src/lib/services/index.ts @@ -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'; diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts index e49a07319..b2f0679f4 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts @@ -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) => { diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index d85aa843b..2d46830b9 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -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 { 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 { + const streams = await this.getVodStreams(credentials, { + ...options, + categoryId, + }); + + return ( + streams.find( + (stream) => Number(stream.stream_id) === Number(vodId) + ) ?? null + ); + } + /** * Get series items */ diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index c1f837f94..a6599ab54 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -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, diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index 8aa9c49b3..b2c8650b6 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -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}`; } /** diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.spec.ts new file mode 100644 index 000000000..a2e55ba94 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.spec.ts @@ -0,0 +1,72 @@ +import { XtreamVodDetails } from '@iptvnator/shared/interfaces'; +import { + resolveXtreamVodPlaybackSource, + XtreamVodPlaybackItem, +} from './xtream-vod-playback-source'; + +function createMovieDataDetails( + overrides: Partial> = {} +): 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(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.ts new file mode 100644 index 000000000..fb2c5caec --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.ts @@ -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) + ); +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index a7d9a4da6..d210d259e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -486,6 +486,9 @@ describe('withContent import state', () => { pendingCategories.live.resolve([]); pendingCategories.vod.resolve([]); pendingCategories.series.resolve([]); + await waitForCondition( + () => store.vodCategoriesPlaylistId() === PLAYLIST.id + ); pending.live.resolve(liveItems); await waitForCondition( @@ -506,6 +509,7 @@ describe('withContent import state', () => { ); expect(store.vodStreams()).toEqual(vodItems); + expect(store.vodStreamsPlaylistId()).toBe(PLAYLIST.id); expect(store.contentLoadStateByType()).toEqual({ live: 'ready', vod: 'ready', @@ -525,6 +529,21 @@ describe('withContent import state', () => { expect(store.isContentInitialized()).toBe(true); }); + it('records the VOD category owner when categories are reloaded', async () => { + dataSource.getCategories.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: 'live' | 'vod' | 'series' + ) => Promise.resolve([{ category_id: type }]) + ); + + await store.reloadCategories(); + + expect(store.vodCategories()).toEqual([{ category_id: 'vod' }]); + expect(store.vodCategoriesPlaylistId()).toBe(PLAYLIST.id); + }); + it('loads categories before starting content import', async () => { const pendingCategories = { live: createDeferred(), @@ -653,12 +672,25 @@ describe('withContent import state', () => { expect(dataSource.getContent).not.toHaveBeenCalled(); expect(store.vodCategories()).toHaveLength(1); expect(store.vodStreams()).toHaveLength(1); + expect(store.vodCategoriesPlaylistId()).toBe(PLAYLIST.id); + expect(store.vodStreamsPlaylistId()).toBe(PLAYLIST.id); expect(store.contentLoadStateByType().vod).toBe('ready'); expect(store.isCachedContentScopeReady('vod')).toBe(true); expect(store.isContentInitialized()).toBe(true); expect(store.contentInitBlockReason()).toBeNull(); }); + it('clears VOD catalog owners with the content state', async () => { + await store.hydrateCachedContent('vod'); + expect(store.vodCategoriesPlaylistId()).toBe(PLAYLIST.id); + expect(store.vodStreamsPlaylistId()).toBe(PLAYLIST.id); + + store.resetContent(); + + expect(store.vodCategoriesPlaylistId()).toBeNull(); + expect(store.vodStreamsPlaylistId()).toBeNull(); + }); + it('exposes loading state while cached section content is hydrating', async () => { const cachedCategories = createDeferred(); const cachedContent = createDeferred(); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 4ba66c065..15ac0f490 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -86,9 +86,11 @@ const clearCancelledPlaylistInitializationLock = (playlistId: string): void => { export interface ContentState { liveCategories: (XtreamCategory | XtreamCategoryFromDb)[]; vodCategories: (XtreamCategory | XtreamCategoryFromDb)[]; + vodCategoriesPlaylistId: string | null; serialCategories: (XtreamCategory | XtreamCategoryFromDb)[]; liveStreams: XtreamLiveStream[]; vodStreams: XtreamVodStream[]; + vodStreamsPlaylistId: string | null; serialStreams: XtreamSerieItem[]; isLoadingCategories: boolean; isLoadingContent: boolean; @@ -119,9 +121,11 @@ const initialContentLoadStateByType: XtreamContentLoadStateByType = { const initialContentState: ContentState = { liveCategories: [], vodCategories: [], + vodCategoriesPlaylistId: null, serialCategories: [], liveStreams: [], vodStreams: [], + vodStreamsPlaylistId: null, serialStreams: [], isLoadingCategories: false, isLoadingContent: false, @@ -541,10 +545,12 @@ export function withContent() { break; case 'vod': updates.vodCategories = entry.categories; + updates.vodCategoriesPlaylistId = playlistId; updates.vodStreams = asCachedContent( entry.content ); + updates.vodStreamsPlaylistId = playlistId; break; case 'series': updates.serialCategories = entry.categories; @@ -984,6 +990,7 @@ export function withContent() { patchState(store, { liveCategories: live, vodCategories: vod, + vodCategoriesPlaylistId: ctx.playlistId, serialCategories: series, isLoadingCategories: false, }), @@ -1113,6 +1120,7 @@ export function withContent() { () => patchState(store, { vodStreams: vod, + vodStreamsPlaylistId: ctx.playlistId, }), () => ({ items: vod.length }) ); @@ -1308,6 +1316,7 @@ export function withContent() { patchState(store, { liveCategories: live, vodCategories: vod, + vodCategoriesPlaylistId: ctx.playlistId, serialCategories: series, }); } catch (error) { diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-category-recovery.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-category-recovery.spec.ts new file mode 100644 index 000000000..e493c5576 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-category-recovery.spec.ts @@ -0,0 +1,97 @@ +import { XtreamVodStream } from '@iptvnator/shared/interfaces'; +import { recoverXtreamVodCatalogItem } from './xtream-details-request'; + +describe('Xtream VOD category recovery', () => { + const credentials = { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }; + + it('tries a local category mapping before a colliding provider id', async () => { + const recoveredItem = { + stream_id: 42, + container_extension: 'mp4', + } as XtreamVodStream; + const getVodStream = jest.fn( + async ( + _credentials: typeof credentials, + _vodId: string | number, + categoryId: string | number + ) => (Number(categoryId) === 7 ? recoveredItem : null) + ); + const getAllCategories = jest.fn(); + const getCategories = jest.fn(); + + await expect( + recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [ + { id: 7, xtream_id: 701 }, + { id: 8, xtream_id: 7 }, + ], + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-a', + routeCategoryId: 7, + vodId: 42, + }) + ).resolves.toBe(recoveredItem); + expect(getVodStream.mock.calls).toEqual([ + [credentials, 42, 701], + [credentials, 42, 7], + ]); + expect(getAllCategories).not.toHaveBeenCalled(); + expect(getCategories).not.toHaveBeenCalled(); + }); + + it('deduplicates one provider category across all category sources', async () => { + const getVodStream = jest.fn().mockResolvedValue(null); + const getAllCategories = jest + .fn() + .mockResolvedValue([{ id: 7, xtream_id: 7 }]); + const getCategories = jest + .fn() + .mockResolvedValue([{ category_id: '7' }]); + + await expect( + recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [{ id: 7, xtream_id: 7 }], + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-a', + routeCategoryId: 7, + vodId: 42, + }) + ).resolves.toBeNull(); + expect(getVodStream).toHaveBeenCalledTimes(1); + expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 7); + }); + + it('does not request a VOD for an unresolved route category', async () => { + const getVodStream = jest.fn(); + const getAllCategories = jest + .fn() + .mockResolvedValue([{ id: 8, xtream_id: 701 }]); + const getCategories = jest + .fn() + .mockResolvedValue([{ category_id: '702' }]); + + await expect( + recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [], + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-a', + routeCategoryId: 7, + vodId: 42, + }) + ).resolves.toBeNull(); + expect(getVodStream).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 000000000..eb06719ee --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.spec.ts @@ -0,0 +1,293 @@ +import { XtreamVodStream } from '@iptvnator/shared/interfaces'; +import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { + createXtreamDetailsRequestGuard, + recoverXtreamVodCatalogItem, + resolveXtreamVodDetailsSelection, +} 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.each([7, 701])( + 'recovers from Electron route category id %s', + async (routeCategoryId) => { + 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, + 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(); + }); + + it('ignores stale catalog owners and resolves against the active playlist', 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: 'mkv', + }); + const resolution = resolveXtreamVodDetailsSelection({ + apiService: { getVodStream }, + currentCategories: [{ id: 7, xtream_id: 999 }], + currentCategoriesPlaylistId: 'playlist-a', + currentStreams: [ + { + stream_id: 42, + container_extension: 'mp4', + xtream_id: 42, + } as XtreamVodStream, + ], + currentStreamsPlaylistId: 'playlist-a', + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-b', + routeCategoryId: 7, + vodDetails: { info: [] }, + 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', + }); + expect(getAllCategories).toHaveBeenCalledWith('playlist-b', 'movies'); + expect(getCategories).not.toHaveBeenCalled(); + expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701); + }); + + 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 = resolveXtreamVodDetailsSelection({ + apiService: { getVodStream }, + currentCategories: [], + currentCategoriesPlaylistId: 'playlist-b', + currentStreams: [ + { + stream_id: 42, + container_extension: 'mp4', + xtream_id: 42, + } as XtreamVodStream, + ], + currentStreamsPlaylistId: 'playlist-b', + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-b', + routeCategoryId: 7, + vodDetails: { info: [] }, + vodId: 42, + }); + + expect(resolveXtreamVodPlaybackSource(result.selection)).toEqual({ + 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 new file mode 100644 index 000000000..7d85a1b48 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.ts @@ -0,0 +1,238 @@ +import { + XtreamVodDetails, + XtreamVodStream, +} from '@iptvnator/shared/interfaces'; +import { XtreamCredentials } from '../services/xtream-api.service'; +import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { + buildXtreamVodSelection, + findXtreamVodCatalogItem, + resolveXtreamVodCatalogCategoryIds, + XtreamVodCatalogCategory, + XtreamVodSelection, +} 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; + }; + readonly currentCategories: readonly XtreamVodCatalogCategory[]; + readonly credentials: XtreamCredentials; + readonly dataSource: { + getAllCategories( + playlistId: string, + type: 'movies' + ): Promise; + getCategories( + playlistId: string, + credentials: XtreamCredentials, + type: 'vod' + ): Promise; + }; + readonly isCurrent: () => boolean; + readonly playlistId: string; + readonly routeCategoryId: string | number; + readonly vodId: string | number; +} + +interface ResolveXtreamVodDetailsSelectionOptions extends Omit< + RecoverXtreamVodCatalogItemOptions, + 'currentCategories' +> { + readonly currentCategories: readonly XtreamVodCatalogCategory[]; + readonly currentCategoriesPlaylistId: PlaylistId; + readonly currentStreams: readonly XtreamVodStream[]; + readonly currentStreamsPlaylistId: PlaylistId; + readonly vodDetails: XtreamVodDetails; +} + +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, + credentials, + dataSource, + isCurrent, + playlistId, + routeCategoryId, + vodId, +}: RecoverXtreamVodCatalogItemOptions): Promise { + if (!isCurrent()) { + return null; + } + + const attemptedCategoryIds = new Set(); + const findInCategories = async ( + categories: readonly XtreamVodCatalogCategory[] + ): Promise => { + const providerCategoryIds = resolveXtreamVodCatalogCategoryIds( + categories, + routeCategoryId + ); + for (const providerCategoryId of providerCategoryIds) { + const normalizedProviderId = Number(providerCategoryId); + if (attemptedCategoryIds.has(normalizedProviderId)) { + continue; + } + attemptedCategoryIds.add(normalizedProviderId); + if (!isCurrent()) { + return null; + } + + const item = await apiService.getVodStream( + credentials, + vodId, + providerCategoryId + ); + if (!isCurrent()) { + return null; + } + if (item) { + return item; + } + } + + return null; + }; + + let item = await findInCategories(currentCategories); + if (item || !isCurrent()) { + return item; + } + + const persistedCategories = await dataSource.getAllCategories( + playlistId, + 'movies' + ); + if (!isCurrent()) { + return null; + } + item = await findInCategories(persistedCategories); + if (item || !isCurrent()) { + return item; + } + + const categories = await dataSource.getCategories( + playlistId, + credentials, + 'vod' + ); + if (!isCurrent()) { + return null; + } + + return findInCategories(categories); +} + +export function resolveXtreamVodDetailsSelection({ + currentCategories, + currentCategoriesPlaylistId, + currentStreams, + currentStreamsPlaylistId, + vodDetails, + ...recoveryOptions +}: ResolveXtreamVodDetailsSelectionOptions): ResolveXtreamVodDetailsSelectionResult { + const catalogItem = + currentStreamsPlaylistId === recoveryOptions.playlistId + ? findXtreamVodCatalogItem(currentStreams, recoveryOptions.vodId) + : undefined; + const selection = buildXtreamVodSelection( + vodDetails, + catalogItem, + recoveryOptions.vodId + ); + + if (resolveXtreamVodPlaybackSource(selection)) { + return { recovery: null, selection }; + } + + const recovery = recoverXtreamVodCatalogItem({ + ...recoveryOptions, + currentCategories: + currentCategoriesPlaylistId === recoveryOptions.playlistId + ? currentCategories + : [], + }) + .then((recoveredCatalogItem) => ({ + recoveredCatalogItem, + selection: recoveredCatalogItem + ? buildXtreamVodSelection( + vodDetails, + catalogItem, + recoveryOptions.vodId, + recoveredCatalogItem + ) + : selection, + })) + .catch((recoveryError: unknown) => ({ + recoveredCatalogItem: null, + recoveryError, + selection, + })); + + return { + recovery, + selection, + }; +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.spec.ts new file mode 100644 index 000000000..b075af04c --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.spec.ts @@ -0,0 +1,151 @@ +import { XtreamVodStream } from '@iptvnator/shared/interfaces'; +import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { + buildXtreamVodSelection, + findXtreamVodCatalogItem, + 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('keeps a complete detail playback pair ahead of a catalog collision', () => { + const catalogItem = { + stream_id: 42, + container_extension: 'mkv', + xtream_id: 42, + } as XtreamVodStream; + + expect( + buildXtreamVodSelection( + { + info: [], + stream_id: 42, + container_extension: 'mp4', + } as unknown as Parameters[0], + catalogItem, + 42 + ) + ).toEqual( + expect.objectContaining({ + stream_id: 42, + container_extension: 'mp4', + }) + ); + }); + + it('falls back to one complete catalog pair instead of mixing fields', () => { + const catalogItem = { + stream_id: 42, + container_extension: 'mkv', + xtream_id: 42, + } as XtreamVodStream; + + expect( + buildXtreamVodSelection( + { + info: [], + stream_id: 99, + } as unknown as Parameters[0], + catalogItem, + 42 + ) + ).toEqual( + expect.objectContaining({ + stream_id: 42, + container_extension: 'mkv', + }) + ); + }); + + it('prefers a complete recovered pair over the cached catalog pair', () => { + expect( + buildXtreamVodSelection( + { info: [] }, + { + stream_id: 42, + container_extension: 'mp4', + } as XtreamVodStream, + 42, + { + stream_id: 42, + container_extension: 'mkv', + } as XtreamVodStream + ) + ).toEqual( + expect.objectContaining({ + stream_id: 42, + container_extension: 'mkv', + }) + ); + }); + + it('does not synthesize a source from incomplete catalog candidates', () => { + const selection = buildXtreamVodSelection( + { info: [] }, + { stream_id: 42 } as XtreamVodStream, + 42, + { container_extension: 'mkv' } as XtreamVodStream + ); + + expect(resolveXtreamVodPlaybackSource(selection)).toBeNull(); + }); + + it('finds a catalog row by its provider VOD id', () => { + const matchingItem = { + stream_id: 42, + container_extension: 'mp4', + } as XtreamVodStream; + + expect( + findXtreamVodCatalogItem( + [{ stream_id: 7 } as XtreamVodStream, matchingItem], + 42 + ) + ).toBe(matchingItem); + }); + + 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('accepts a provider category id from cross-portal detail links', () => { + expect( + resolveXtreamVodCatalogCategoryId([{ id: 7, xtream_id: 701 }], 701) + ).toBe(701); + }); + + it('does not treat an optional PWA id as a database category id', () => { + expect( + resolveXtreamVodCatalogCategoryId([{ id: 7, category_id: 701 }], 7) + ).toBeNull(); + }); + + it('does not treat an unresolved database category id as a provider id', () => { + expect(resolveXtreamVodCatalogCategoryId([], 7)).toBeNull(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.ts new file mode 100644 index 000000000..07c062732 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.ts @@ -0,0 +1,111 @@ +import { + XtreamVodDetails, + XtreamVodStream, +} from '@iptvnator/shared/interfaces'; +import { + resolveXtreamVodPlaybackSource, + 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, + recoveredCatalogItem?: XtreamVodStream +): XtreamVodSelection { + const detailItem = vodDetails as XtreamVodPlaybackItem; + const playbackSource = + resolveXtreamVodPlaybackSource(detailItem) ?? + resolveXtreamVodPlaybackSource( + recoveredCatalogItem as unknown as XtreamVodPlaybackItem + ) ?? + resolveXtreamVodPlaybackSource( + catalogItem as unknown as XtreamVodPlaybackItem + ); + + return { + ...catalogItem, + ...recoveredCatalogItem, + ...vodDetails, + stream_id: playbackSource?.streamId ?? Number(vodId), + container_extension: playbackSource?.containerExtension ?? null, + xtream_id: + recoveredCatalogItem?.xtream_id ?? + catalogItem?.xtream_id ?? + Number(vodId), + }; +} + +export function findXtreamVodCatalogItem( + items: readonly XtreamVodStream[], + vodId: string | number +): XtreamVodStream | undefined { + return items.find((item) => { + const candidateId = + item.xtream_id ?? + item.stream_id ?? + (item as { id?: string | number }).id; + + return Number(candidateId) === Number(vodId); + }); +} + +export function resolveXtreamVodCatalogCategoryId( + categories: readonly XtreamVodCatalogCategory[], + categoryId: string | number +): string | number | null { + return ( + resolveXtreamVodCatalogCategoryIds(categories, categoryId)[0] ?? null + ); +} + +export function resolveXtreamVodCatalogCategoryIds( + categories: readonly XtreamVodCatalogCategory[], + categoryId: string | number +): (string | number)[] { + const normalizedCategoryId = Number(categoryId); + if (!Number.isFinite(normalizedCategoryId)) { + return []; + } + + const candidates: (string | number)[] = []; + const seen = new Set(); + const addProviderId = (category: XtreamVodCatalogCategory): void => { + const providerId = category.xtream_id ?? category.category_id; + if (providerId === undefined || providerId === null) { + return; + } + const normalizedProviderId = Number(providerId); + if ( + Number.isFinite(normalizedProviderId) && + !seen.has(normalizedProviderId) + ) { + seen.add(normalizedProviderId); + candidates.push(providerId); + } + }; + const matches = (value: string | number | undefined): boolean => + value !== undefined && Number(value) === normalizedCategoryId; + + categories + .filter((item) => item.xtream_id !== undefined && matches(item.id)) + .forEach(addProviderId); + categories + .filter((item) => matches(item.xtream_id) || matches(item.category_id)) + .forEach(addProviderId); + + return candidates; +} 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 d16aa5160..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 @@ -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,13 @@ import { enrichSerialSelectionWithTmdb, enrichVodSelectionWithTmdb, } from './xtream-tmdb-enrichment'; +import { + applyRecoveredXtreamVodCatalogItem, + createXtreamDetailsRequestGuard, + resolveXtreamVodDetailsSelection, +} from './xtream-details-request'; -/** - * 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 +48,6 @@ export const XtreamStore = signalStore( withRecentItems(), withPlaybackPositions(), - // Cross-feature computed properties withComputed((store) => ({ /** * Get global recent items (from withRecentItems) @@ -76,21 +63,20 @@ export const XtreamStore = signalStore( const dataSource = inject(XTREAM_DATA_SOURCE); const tmdbEnrichment = inject(TmdbEnrichmentService); const logger = createLogger('XtreamStore'); - const findVodCatalogItem = (vodId: string | number) => - store.vodStreams().find((item) => { - const candidateId = - item.xtream_id ?? - item.stream_id ?? - (item as { id?: string | number }).id; - - return Number(candidateId) === Number(vodId); - }); - + const detailsRequestGuard = createXtreamDetailsRequestGuard( + () => store.currentPlaylist()?.id + ); 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,38 +134,80 @@ 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 - ) + .getVodInfo(credentials, params.vodId) .then((vodDetails: XtreamVodDetails) => { - const catalogItem = findVodCatalogItem(params.vodId); + if (!isCurrentRequest()) return; + const { recovery, selection } = + resolveXtreamVodDetailsSelection({ + apiService: xtreamApiService, + currentCategories: store.vodCategories(), + currentCategoriesPlaylistId: + store.vodCategoriesPlaylistId(), + currentStreams: store.vodStreams(), + currentStreamsPlaylistId: + store.vodStreamsPlaylistId(), + credentials, + dataSource, + isCurrent: isCurrentRequest, + playlistId: playlist.id, + routeCategoryId: params.categoryId, + vodDetails, + vodId: params.vodId, + }); + 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, 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; logger.error('Error fetching VOD details', error); store.setDetailsError( error instanceof Error @@ -188,7 +216,9 @@ export const XtreamStore = signalStore( ); }) .finally(() => { - store.setIsLoadingDetails(false); + if (isCurrentRequest()) { + store.setIsLoadingDetails(false); + } }); }, @@ -202,19 +232,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 +257,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 +266,9 @@ export const XtreamStore = signalStore( ); }) .finally(() => { - store.setIsLoadingDetails(false); + if (isCurrentRequest()) { + store.setIsLoadingDetails(false); + } }); }, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 78c2a02b6..c7692464c 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index edb4362bf..e4bbb488e 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -302,6 +302,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.xtreamStore.cancelDetailsRequest(); this.playback.closeInlinePlayer(); this.xtreamStore.setSelectedItem(null); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback-presentation.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback-presentation.ts new file mode 100644 index 000000000..f3fd91cad --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback-presentation.ts @@ -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; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 1b82f26c1..1e216c27d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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( - null - ); + private readonly bindings = signal(null); readonly inlinePlayback = signal(null); readonly vodPlaybackPosition = signal(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, }; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts new file mode 100644 index 000000000..2fdb27db7 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -0,0 +1,343 @@ +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, + XtreamCategory, + XtreamVodDetails, + XtreamVodStream, +} 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: '
', +}) +class StubPortalInlinePlayerComponent { + readonly playback = input(null); + readonly timeUpdate = output(); + readonly closed = output(); + readonly backClicked = output(); + readonly streamUrlCopied = output(); + readonly externalFallbackRequested = output(); +} +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; + const selectedItem = signal(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 vodStreams = signal[]>([]); + const vodCategories = signal[]>([]); + const vodStreamsPlaylistId = signal('playlist-1'); + const vodCategoriesPlaylistId = signal('playlist-1'); + 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); + currentPlaylist.set({ + id: 'playlist-1', + userAgent: 'IPTVnator', + referrer: 'https://referrer.example', + origin: 'https://origin.example', + }); + vodStreams.set([]); + vodCategories.set([]); + vodStreamsPlaylistId.set('playlist-1'); + vodCategoriesPlaylistId.set('playlist-1'); + 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, + vodCategories, + vodStreamsPlaylistId, + vodCategoriesPlaylistId, + 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('ignores catalog data owned by another playlist', () => { + currentPlaylist.set({ + id: 'playlist-b', + userAgent: 'IPTVnator', + referrer: 'https://referrer.example', + origin: 'https://origin.example', + }); + vodStreamsPlaylistId.set('playlist-a'); + vodCategoriesPlaylistId.set('playlist-a'); + vodStreams.set([ + { + name: 'Stale catalog title', + stream_id: 650020, + container_extension: 'mkv', + }, + ]); + vodCategories.set([ + { + category_id: '235', + category_name: 'Stale category', + }, + ]); + + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedCatalogItem()).toBeNull(); + expect(fixture.componentInstance.selectedCategory()).toBeNull(); + }); + 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('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( + 'button.play-btn--resume' + )?.click(); + fixture.detectChanges(); + host.querySelector('button.favorite-btn')?.click(); + host.querySelector('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('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'); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index dba1e722c..f7646e2f5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -1,3 +1,132 @@ + + @if (playableVodItem(); as playableItem) { + @if (hasPlaybackPosition()) { + + @if (!isExternalStopAction()) { + + } + } @else { + + } + @if (!isFavorite()) { + + } @else { + + } + @if (isElectron()) { + @if (isDownloaded()) { + + } @else if (isPausedDownload()) { + + } @else if (isDownloading()) { + + } @else { + + } + } + } + + + + @if (playableVodItem()) { + @if (inlinePlayback(); as playback) { + + } + } + + @let item = selectedItem(); @if (selectedVodInfo(); as info) { {{ 'XTREAM.ACTORS' | translate }} @if (info.tmdb_cast?.length) {
- @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 + ) { - {{ 'XTREAM.DIRECTOR' | translate }} + {{ + 'XTREAM.DIRECTOR' | translate + }} @if (info.tmdb_directors?.length) {
- @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 + ) { - - - @if (externalPrimaryLabel(); as label) { - {{ label }} - } @else { - {{ 'XTREAM.RESUME' | translate }} - {{ formatPosition() }} - } - - - @if (!isExternalStopAction()) { - - } - } @else { - - } - @if (!isFavorite()) { - - } @else { - - } - @if (isElectron()) { - @if (isDownloaded()) { - - } @else if (isPausedDownload()) { - - } @else if (isDownloading()) { - - } @else { - - } - } + - - @if (inlinePlayback(); as playback) { - - } +
@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 ) {