diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 847dfae72..dd95f226b 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -1144,8 +1144,8 @@ reaches history. the portal `cmd`, never that link. Writers capture the item and its playlist when they defer, so navigating meanwhile cannot misfile it; an Xtream write confirmed after a switch to another playlist (only a slow - MPV/VLC launch can) is dropped rather than replacing that playlist's - recent list. + MPV/VLC launch can) is saved to its own playlist without reloading the + store's recent list, which belongs to the other playlist by then. - Matching: a write deferred with a session key is confirmed only by that same key — the same URL in two playlists must not let playback in one (inline, or in MPV/VLC) record a failed attempt in the other. Writes diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index b7f87ad48..766f36cff 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -5,7 +5,10 @@ import { PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, } from '@iptvnator/portal/shared/util'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import type { @@ -29,6 +32,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => { /** The bridge callback the service registers at construction. */ let positionListener: ((data: PlaybackPositionData) => void) | undefined; const addRecentItem = jest.fn(); + const xtreamDataSource = { + getContentByXtreamId: jest.fn(), + addRecentItem: jest.fn(), + }; const activeSession = signal(null); const closeSession = jest.fn().mockResolvedValue(undefined); const openResolvedPlayback = jest.fn(); @@ -82,6 +89,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => { routeVodId.set(ROUTE_VOD_ID); positionListener = undefined; addRecentItem.mockClear(); + xtreamDataSource.getContentByXtreamId + .mockReset() + .mockResolvedValue({ id: 77 }); + xtreamDataSource.addRecentItem.mockReset().mockResolvedValue(undefined); closeSession.mockReset().mockResolvedValue(undefined); openResolvedPlayback .mockReset() @@ -106,6 +117,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { .mockReturnValue('https://example.com/route.mkv'), }, }, + { provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource }, { provide: PORTAL_EXTERNAL_PLAYBACK, useValue: { activeSession, closeSession }, @@ -174,7 +186,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(recentItem.playlist()).toEqual({ id: ROUTE_PLAYLIST }); }); - it('drops a confirmation that arrives after a switch to another playlist', async () => { + it('saves a confirmation that arrives after a playlist switch without touching the store', async () => { // Only a slow MPV/VLC launch can confirm after the page is gone; the // store's recent list belongs to the other playlist by then. await service.startResolvedPlayback({ @@ -186,8 +198,19 @@ describe('VodDetailsPlaybackService — external session ownership', () => { TestBed.inject(PlaybackHistoryGate).confirm({ streamUrls: ['https://example.com/route.mkv'], }); + await Promise.resolve(); expect(addRecentItem).not.toHaveBeenCalled(); + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + ROUTE_VOD_ID, + ROUTE_PLAYLIST, + 'movie' + ); + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + 77, + ROUTE_PLAYLIST, + undefined + ); }); it('owns a session launched for the route’s own stream', () => { diff --git a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts index b24b739ca..d0ed7e67a 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -1,6 +1,10 @@ -import { inject, signal } from '@angular/core'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { inject, Injector, signal } from '@angular/core'; import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +import { + type IXtreamDataSource, + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; export interface XtreamRecentItemRequest { readonly xtreamId: number | string; @@ -12,13 +16,13 @@ export interface XtreamRecentItemRequest { * Records a movie or series as recently viewed once `streamUrl` has really * played — inline for a couple of seconds, or launched in MPV/VLC — so a * source that fails straight away never reaches history or the dashboard - * hero. + * hero. The playlist is captured when playback starts, so navigating to + * another one meanwhile cannot misfile the item. * * A confirmation that arrives after the user switched to another playlist * (only a slow MPV/VLC launch can: the inline player goes with the page) is - * dropped: the store's recent list belongs to the other playlist by then, - * and recording it would misfile the item or replace that list. The check - * lives here, off the initial bundle the Xtream store ships in. + * saved to the captured playlist without touching the store, whose recent + * list belongs to the other playlist by then. * * Must run in an injection context. */ @@ -28,13 +32,48 @@ export function injectXtreamRecentHistory(): ( ) => void { const gate = inject(PlaybackHistoryGate); const store = inject(XtreamStore); + const injector = inject(Injector); return (streamUrl, request) => { const playlist = signal(store.currentPlaylist()).asReadonly(); gate.defer({ streamUrls: [streamUrl] }, () => { - if (store.currentPlaylist()?.id === playlist()?.id) { + const playlistId = playlist()?.id; + if (store.currentPlaylist()?.id === playlistId) { store.addRecentItem({ ...request, playlist }); + } else if (playlistId) { + void saveWithoutListRefresh( + injector.get(XTREAM_DATA_SOURCE), + playlistId, + request + ); } }); }; } + +/** + * The save half of `withRecentItems.addRecentItem` (same content lookup and + * PWA fallback to the Xtream id for cold content), without reloading the + * store's recent list. Kept here rather than shared with the store: the + * store and its library barrel ship in the initial bundle, this path is + * only reached from lazy detail pages. + */ +async function saveWithoutListRefresh( + dataSource: IXtreamDataSource, + playlistId: string, + { xtreamId, contentType, backdropUrl }: XtreamRecentItemRequest +): Promise { + const id = Number(xtreamId); + if (!Number.isFinite(id) || id <= 0) { + return; + } + const content = await dataSource.getContentByXtreamId( + id, + playlistId, + contentType + ); + const contentId = content?.id ?? (!window.electron ? id : null); + if (contentId != null) { + await dataSource.addRecentItem(contentId, playlistId, backdropUrl); + } +}