diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts index 845dc0ce8..889b9f0ed 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts @@ -229,6 +229,26 @@ describe('VideoPlayerComponent — recently viewed history', () => { ); }); + it('records a second row of the same URL that plays after the first failed', () => { + // One stream listed twice in a playlist, under two channel ids. + select(); + select({ + ...sampleChannel, + id: 'channel-1-copy', + name: 'Sample TV HD', + }); + + gate.confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: [sampleChannel.url], + }); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith( + 'playlist-1', + expect.objectContaining({ title: 'Sample TV HD' }) + ); + }); + it('records a radio station once the audio player confirms it', () => { const radio = { ...sampleChannel, radio: 'true' }; select(radio); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 01e1fb486..39040c24d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -850,7 +850,9 @@ export class VideoPlayerComponent return; } - const nextKey = `${playlistId}::${activeChannel.url}`; + // Channel identity too: two rows of one URL are separate + // attempts, confirmed under their own session keys. + const nextKey = `${playlistId}::${activeChannel.id}::${activeChannel.url}`; if (this.lastRecordedRecentKey === nextKey) { return; } 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 766f36cff..0ff0d85a3 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 @@ -9,7 +9,10 @@ import { XTREAM_DATA_SOURCE, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; -import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { + PlaybackPositionRuntimeBridgeService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import type { PlaybackPositionData, @@ -213,6 +216,42 @@ describe('VodDetailsPlaybackService — external session ownership', () => { ); }); + it.each([ + ['the API-only data source', false, 1], + ['the SQLite data source', true, 0], + ])( + 'keys a late, uncached write by Xtream id only for %s', + async (_label, sqlite: boolean, saves: number) => { + // A partial Electron bridge still selects the API-only source. + jest.spyOn( + TestBed.inject(RuntimeCapabilitiesService), + 'supportsXtreamSqliteDataSource', + 'get' + ).mockReturnValue(sqlite); + xtreamDataSource.getContentByXtreamId.mockResolvedValue(null); + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/route.mkv', + title: 'Slow external launch', + }); + currentPlaylist.set({ id: 'playlist-switched-meanwhile' }); + + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['https://example.com/route.mkv'], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledTimes(saves); + if (saves) { + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + ROUTE_VOD_ID, + ROUTE_PLAYLIST, + undefined + ); + } + } + ); + it('owns a session launched for the route’s own stream', () => { activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); 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 d0ed7e67a..222935734 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -1,5 +1,6 @@ import { inject, Injector, signal } from '@angular/core'; import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { type IXtreamDataSource, XTREAM_DATA_SOURCE, @@ -43,6 +44,10 @@ export function injectXtreamRecentHistory(): ( } else if (playlistId) { void saveWithoutListRefresh( injector.get(XTREAM_DATA_SOURCE), + // The data source factory picks SQLite by this contract, + // not by a generic Electron bridge. + !injector.get(RuntimeCapabilitiesService) + .supportsXtreamSqliteDataSource, playlistId, request ); @@ -52,14 +57,15 @@ export function injectXtreamRecentHistory(): ( } /** - * 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 + * The save half of `withRecentItems.addRecentItem` (same content lookup; + * the API-only data source keys cold content by its Xtream id), 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, + keysByXtreamId: boolean, playlistId: string, { xtreamId, contentType, backdropUrl }: XtreamRecentItemRequest ): Promise { @@ -72,7 +78,7 @@ async function saveWithoutListRefresh( playlistId, contentType ); - const contentId = content?.id ?? (!window.electron ? id : null); + const contentId = content?.id ?? (keysByXtreamId ? id : null); if (contentId != null) { await dataSource.addRecentItem(contentId, playlistId, backdropUrl); }