diff --git a/.changes/playback-history-after-playback.md b/.changes/playback-history-after-playback.md new file mode 100644 index 000000000..0b6935611 --- /dev/null +++ b/.changes/playback-history-after-playback.md @@ -0,0 +1,6 @@ +--- +type: feature +area: playback +--- + +Channels, movies and episodes now join Recently Viewed and the dashboard's Continue Watching hero only after they have actually played for a couple of seconds, so streams that fail right away no longer clutter your history. diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index 511f6f070..89deaa4de 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -31,6 +31,7 @@ import { waitForM3uCatalog, workspaceRoot, } from './electron-test-fixtures'; +import { startAndConfirmPlayback } from './playable-stream-fixture'; /** * DASH + ClearKey playback in the real Electron runtime — the only automated @@ -541,7 +542,8 @@ for (const configuredPlayer of ['videojs', 'mpv', 'artplayer']) { buildDashPlaylist(fixtureServer.origin) ); const channel = channelItemByTitle(page, 'ClearKey DASH').first(); - await channel.click(); + // Recent history records the channel once it has really played. + await startAndConfirmPlayback(page, () => channel.click()); await channel.locator('.favorite-button').click(); await expect( channel.locator('.favorite-button mat-icon') diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 86a19d0d0..5dfb3e3e0 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -25,6 +25,10 @@ import { getXtreamTitle, pickDistinctTitles, } from './portal-mock-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; test.describe('Dashboard Activation', () => { test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({ @@ -76,6 +80,7 @@ test.describe('Dashboard Activation', () => { return new RegExp(titles.join('|')); }; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addXtreamPortal(app.mainWindow, { @@ -99,7 +104,10 @@ test.describe('Dashboard Activation', () => { ); const movieTitle = await clickFirstGridListCard(app.mainWindow); await addCurrentDetailToFavorites(app.mainWindow); - await playCurrentDetail(app.mainWindow); + // Recorded as recently viewed once it has really played. + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -111,7 +119,9 @@ test.describe('Dashboard Activation', () => { ); const seriesTitle = await clickFirstGridListCard(app.mainWindow); await addCurrentDetailToFavorites(app.mainWindow); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await goToDashboard(app.mainWindow); @@ -176,11 +186,13 @@ test.describe('Dashboard Activation', () => { liveTitle ).locator('.rail__channel-now') ).toContainText(liveNowTitle(), { timeout: 30000 }); - await dashboardRailCardByTitle( - app.mainWindow, - 'dashboard-live-favorites-rail', - liveTitle - ).click(); + await startAndConfirmPlayback(app.mainWindow, () => + dashboardRailCardByTitle( + app.mainWindow, + 'dashboard-live-favorites-rail', + liveTitle + ).click() + ); await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/favorites$/ ); diff --git a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts index 9c99fcad8..704a8b920 100644 --- a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts +++ b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts @@ -20,6 +20,10 @@ import { writeTemporaryM3uFile, } from './electron-test-fixtures'; import { fetchXtreamLiveFixture } from './portal-mock-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; /** * Issue #1458, second report: "all channels disappear after clearing the @@ -136,6 +140,7 @@ test.describe('Live channel rail collapse (#1458)', () => { CHANNELS ); const app: LaunchedElectronApp = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, filePath); @@ -143,9 +148,12 @@ test.describe('Live channel rail collapse (#1458)', () => { const playlistId = playlistIdFromUrl(app.mainWindow); expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); - await channelItemByTitle(app.mainWindow, 'Channel Alpha') - .first() - .click(); + // History records a channel only once it has really played. + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Channel Alpha') + .first() + .click() + ); await openPlaylistRecent(app.mainWindow); await expect( diff --git a/apps/electron-backend-e2e/src/playable-stream-fixture.ts b/apps/electron-backend-e2e/src/playable-stream-fixture.ts new file mode 100644 index 000000000..e161f457c --- /dev/null +++ b/apps/electron-backend-e2e/src/playable-stream-fixture.ts @@ -0,0 +1,159 @@ +import { expect, type Page, type Route } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { xtreamMockServer } from './electron-test-fixtures'; + +/** + * Local, deterministic media for flows that must really play. + * + * A channel, movie or episode is only recorded as recently viewed once its + * stream has advanced for two seconds, so history tests can no longer use + * unreachable URLs or the public HLS streams the portal mocks redirect to. + * These routes answer those requests from the renderer with fixtures that + * the Electron player actually decodes: + * - `.m3u8` → a one-segment HLS playlist over the H.264/AAC MPEG-TS clip; + * - `.ts` → that clip (6 s) directly; + * - anything else (mp4, mkv, avi, webm) → the VP8 WebM clip (30 s), which + * Chromium sniffs from its bytes regardless of the declared type. + */ +const SEGMENT_URL = 'https://playable-stream-fixture.test/segment.ts'; +const transportStream = readFileSync( + join(__dirname, '../../xtream-mock-server/src/fixtures/live.mpegts') +); +const webmClip = readFileSync( + join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm') +); +const hlsPlaylist = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:6', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXTINF:6,', + SEGMENT_URL, + '#EXT-X-ENDLIST', + '', +].join('\n'); + +/** Hosts whose streams tests play: fixtures, portal mocks, mock redirects. */ +const PLAYABLE_STREAM_HOSTS = [ + 'https://streams.example.test', + 'https://playable-stream-fixture.test', + // Stalker mock `create_link` answers and the Xtream mock redirect target. + 'https://test-streams.mux.dev', + 'https://devstreaming-cdn.apple.com', + 'https://playertest.longtailvideo.com', +]; +const XTREAM_MEDIA_PATH = /^\/(live|movie|series)\//; + +export async function routePlayableStreams(page: Page): Promise { + await page.route( + (url) => + PLAYABLE_STREAM_HOSTS.includes(url.origin) || + (url.origin === new URL(xtreamMockServer).origin && + XTREAM_MEDIA_PATH.test(url.pathname)), + (route) => fulfillPlayableStream(route) + ); +} + +/** + * Runs `start` and waits until a media element it brought up has played for + * `seconds` of real stream time — longer than the two seconds after which + * the app records the item. Elements already on the page are ignored, so a + * still-mounted previous player cannot satisfy the wait. + */ +export async function startAndConfirmPlayback( + page: Page, + start: () => Promise, + seconds = 2.5 +): Promise { + await page.evaluate(() => + document + .querySelectorAll('video, audio') + .forEach((media) => media.setAttribute('data-e2e-previous', '')) + ); + await start(); + await expect + .poll( + () => + page.evaluate((minimum) => { + const starts = (( + window as unknown as { + __e2eMediaStarts?: WeakMap; + } + ).__e2eMediaStarts ??= new WeakMap()); + return [ + ...document.querySelectorAll( + 'video:not([data-e2e-previous]), audio:not([data-e2e-previous])' + ), + ].some((media) => { + if (media.paused || media.readyState < 2) { + return false; + } + if (!starts.has(media)) { + starts.set(media, media.currentTime); + } + return ( + media.currentTime - (starts.get(media) ?? 0) >= + minimum + ); + }); + }, seconds), + { timeout: 45_000 } + ) + .toBe(true); +} + +async function fulfillPlayableStream(route: Route): Promise { + const pathname = new URL(route.request().url()).pathname; + if (pathname.endsWith('.m3u8')) { + await route.fulfill({ + status: 200, + contentType: 'application/vnd.apple.mpegurl', + body: hlsPlaylist, + }); + return; + } + if (pathname.endsWith('.ts')) { + await route.fulfill({ + status: 200, + contentType: 'video/mp2t', + body: transportStream, + }); + return; + } + await fulfillRange(route, webmClip, 'video/webm'); +} + +/** + * Chromium's media pipeline reads files through byte ranges; answering a + * Range request with the whole body would clamp every read to the start. + */ +async function fulfillRange( + route: Route, + body: Buffer, + contentType: string +): Promise { + const range = /^bytes=(\d*)-(\d*)$/.exec( + route.request().headers()['range'] ?? '' + ); + const last = body.length - 1; + const start = range?.[1] + ? Number(range[1]) + : range?.[2] + ? Math.max(0, body.length - Number(range[2])) + : 0; + const end = + range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; + await route.fulfill({ + status: range ? 206 : 200, + headers: { + 'accept-ranges': 'bytes', + 'content-length': String(end - start + 1), + 'content-type': contentType, + ...(range + ? { 'content-range': `bytes ${start}-${end}/${body.length}` } + : {}), + }, + body: body.subarray(start, end + 1), + }); +} diff --git a/apps/electron-backend-e2e/src/player-theme.e2e.ts b/apps/electron-backend-e2e/src/player-theme.e2e.ts index d7df3f324..5933ea7bc 100644 --- a/apps/electron-backend-e2e/src/player-theme.e2e.ts +++ b/apps/electron-backend-e2e/src/player-theme.e2e.ts @@ -139,15 +139,20 @@ for (const engine of [ ) .toBeGreaterThan(0); } else { + // Past the two seconds after which the channel is recorded + // as recently viewed: the HTML5 pass reopens it from there. await expect - .poll(() => - app.mainWindow - .locator('app-web-player-view video') - .evaluate( - (video: HTMLVideoElement) => video.currentTime - ) + .poll( + () => + app.mainWindow + .locator('app-web-player-view video') + .evaluate( + (video: HTMLVideoElement) => + video.currentTime + ), + { timeout: 20000 } ) - .toBeGreaterThan(0); + .toBeGreaterThan(2.5); } const controls = app.mainWindow.locator( engine === 'native' @@ -159,6 +164,10 @@ for (const engine of [ exact: true, }); await expect(pause).toBeEnabled({ timeout: 20000 }); + if (engine !== 'native') { + // Seconds of playback let the controls auto-hide; reveal them. + await app.mainWindow.locator('app-web-player-view').hover(); + } await pause.click(); const play = controls.getByRole('button', { name: 'Play', diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts index dbf6e0271..024972a30 100644 --- a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -30,6 +30,10 @@ import { writeTemporaryM3uFile, xtreamMockServer, } from './electron-test-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; const xtreamStressUsername = 'stress'; const xtreamStressPassword = 'stress'; @@ -202,6 +206,7 @@ test.describe('Electron Playlist Switcher', () => { }, ]); const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, localAPath); @@ -213,9 +218,12 @@ test.describe('Electron Playlist Switcher', () => { .click(); await waitForM3uCatalog(app.mainWindow); await toggleFavoriteForChannel(app.mainWindow, localAChannelName); - await channelItemByTitle(app.mainWindow, localAChannelName) - .first() - .click(); + // Recorded as recently viewed once it has really played. + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, localAChannelName) + .first() + .click() + ); await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, localBDisplayName) @@ -223,9 +231,12 @@ test.describe('Electron Playlist Switcher', () => { .click(); await waitForM3uCatalog(app.mainWindow); await toggleFavoriteForChannel(app.mainWindow, localBChannelName); - await channelItemByTitle(app.mainWindow, localBChannelName) - .first() - .click(); + // Recorded as recently viewed once it has really played. + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, localBChannelName) + .first() + .click() + ); await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, localADisplayName) diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index d419d180e..a82a4f086 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -41,6 +41,10 @@ import { getXtreamTitle, pickDistinctTitles, } from './portal-mock-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; test.describe('Electron Recently Viewed', () => { test('keeps unified live detail open when re-clicking the active M3U recent item', async ({ @@ -58,14 +62,17 @@ test.describe('Electron Recently Viewed', () => { ] ); const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); - await channelItemByTitle(app.mainWindow, 'Stable Recent Channel') - .first() - .click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Stable Recent Channel') + .first() + .click() + ); await openPlaylistRecent(app.mainWindow); @@ -109,17 +116,22 @@ test.describe('Electron Recently Viewed', () => { }, ]); const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); - await channelItemByTitle(app.mainWindow, 'Recent Channel One') - .first() - .click(); - await channelItemByTitle(app.mainWindow, 'Recent Channel Two') - .first() - .click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Recent Channel One') + .first() + .click() + ); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Recent Channel Two') + .first() + .click() + ); await openPlaylistRecent(app.mainWindow); await expect @@ -188,17 +200,22 @@ test.describe('Electron Recently Viewed', () => { ] ); const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); - await channelItemByTitle(app.mainWindow, 'Context Recent One') - .first() - .click(); - await channelItemByTitle(app.mainWindow, 'Context Recent Two') - .first() - .click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Context Recent One') + .first() + .click() + ); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Context Recent Two') + .first() + .click() + ); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); @@ -247,6 +264,7 @@ test.describe('Electron Recently Viewed', () => { ); const portalTitle = 'Xtream Recent Source'; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addXtreamPortal(app.mainWindow, { @@ -261,7 +279,9 @@ test.describe('Electron Recently Viewed', () => { ); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); - await channelItemByTitle(app.mainWindow, liveTitle).first().click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, liveTitle).first().click() + ); await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle); await app.mainWindow @@ -277,7 +297,9 @@ test.describe('Electron Recently Viewed', () => { // card, avoiding a race where the grid re-renders between title read and // a separate search-by-title click. const movieTitle = await clickFirstGridListCard(app.mainWindow); - await playCurrentDetail(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -288,7 +310,9 @@ test.describe('Electron Recently Viewed', () => { seriesFixture.categoryName ); const seriesTitle = await clickFirstGridListCard(app.mainWindow); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await openPlaylistRecent(app.mainWindow); await expect( @@ -362,6 +386,7 @@ test.describe('Electron Recently Viewed', () => { ); const portalTitle = 'Xtream Recent Detail Source'; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addXtreamPortal(app.mainWindow, { @@ -377,7 +402,9 @@ test.describe('Electron Recently Viewed', () => { vodFixture.categoryName ); const movieTitle = await clickFirstGridListCard(app.mainWindow); - await playCurrentDetail(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -388,7 +415,9 @@ test.describe('Electron Recently Viewed', () => { seriesFixture.categoryName ); const seriesTitle = await clickFirstGridListCard(app.mainWindow); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await openPlaylistRecent(app.mainWindow); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( @@ -455,6 +484,7 @@ test.describe('Electron Recently Viewed', () => { ); const portalTitle = 'Stalker Recent Source'; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addStalkerPortal(app.mainWindow, { @@ -468,7 +498,9 @@ test.describe('Electron Recently Viewed', () => { await clickCategoryById(app.mainWindow, liveFixture.categoryId); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); - await channelItemByTitle(app.mainWindow, liveTitle).first().click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, liveTitle).first().click() + ); await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle); await app.mainWindow @@ -476,7 +508,9 @@ test.describe('Electron Recently Viewed', () => { .click(); await clickCategoryById(app.mainWindow, vodFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, movieTitle); - await playCurrentDetail(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -484,7 +518,9 @@ test.describe('Electron Recently Viewed', () => { .click(); await clickCategoryById(app.mainWindow, seriesFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, seriesTitle); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await openPlaylistRecent(app.mainWindow); await expect( @@ -559,6 +595,7 @@ test.describe('Electron Recently Viewed', () => { ); const portalTitle = 'Stalker Recent Detail Source'; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addStalkerPortal(app.mainWindow, { @@ -571,7 +608,9 @@ test.describe('Electron Recently Viewed', () => { .click(); await clickCategoryById(app.mainWindow, vodFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, movieTitle); - await playCurrentDetail(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -579,7 +618,9 @@ test.describe('Electron Recently Viewed', () => { .click(); await clickCategoryById(app.mainWindow, seriesFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, seriesTitle); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await openPlaylistRecent(app.mainWindow); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts index fcb0b5f06..1f2efc953 100644 --- a/apps/web-e2e/src/dash-clearkey.e2e.ts +++ b/apps/web-e2e/src/dash-clearkey.e2e.ts @@ -189,6 +189,19 @@ test('@web @m3u @dash ClearKey reopens from recent and favorites collections', a await importDashPlaylist(page); await page.getByText('1. ClearKey DASH').click(); await expectVideoPlaying(page); + // Recent history records the channel once it has played two seconds. + await expect + .poll( + () => + page + .locator('app-web-player-view video') + .first() + .evaluate( + (element: HTMLVideoElement) => element.currentTime + ), + { timeout: 20_000 } + ) + .toBeGreaterThan(2.5); const playlistUrl = page.url().replace(/\/all$/, ''); const channel = page.locator('.channel-list-item').filter({ hasText: '1. ClearKey DASH', diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index be2a49cd9..b14be957d 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -431,7 +431,7 @@ for (const theme of ['light', 'dark']) { const channels = Array.from( { length: 60 }, (_, index) => - `#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.m3u8` + `#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.webm` ); await importPlaylist(page, ['#EXTM3U', ...channels].join('\n'), 60); await page.evaluate( @@ -462,6 +462,19 @@ for (const theme of ['light', 'dark']) { await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass( /active/ ); + // Recently viewed lists the station once it has played two seconds. + await expect + .poll( + () => + page + .locator('app-web-player-view video') + .first() + .evaluate( + (video: HTMLVideoElement) => video.currentTime + ), + { timeout: 20_000 } + ) + .toBeGreaterThan(2.5); await pressTab(page, browserName); const favorite = viewport.locator('.favorite-button').nth(1); await expect(favorite).toBeFocused(); diff --git a/apps/web/src/app/services/external-playback.service.spec.ts b/apps/web/src/app/services/external-playback.service.spec.ts index ab7fee381..043bb1a3f 100644 --- a/apps/web/src/app/services/external-playback.service.spec.ts +++ b/apps/web/src/app/services/external-playback.service.spec.ts @@ -1,3 +1,5 @@ +import { TestBed } from '@angular/core/testing'; +import { PlaybackHistoryGate } from '@iptvnator/services'; import { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; import { ExternalPlaybackService } from './external-playback.service'; @@ -40,7 +42,36 @@ describe('ExternalPlaybackService', () => { }, }); - service = new ExternalPlaybackService(); + service = TestBed.runInInjectionContext( + () => new ExternalPlaybackService() + ); + }); + + it('commits deferred history once the external player has opened', () => { + const commit = jest.fn(); + TestBed.inject(PlaybackHistoryGate).defer( + ['https://example.com/video.m3u8'], + commit + ); + + listener?.(createSession({ status: 'launching' })); + expect(commit).not.toHaveBeenCalled(); + + listener?.(createSession({ status: 'opened' })); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not commit history for a launch that failed', () => { + const commit = jest.fn(); + TestBed.inject(PlaybackHistoryGate).defer( + ['https://example.com/video.m3u8'], + commit + ); + + listener?.(createSession({ status: 'launching' })); + listener?.(createSession({ status: 'error', error: 'not found' })); + + expect(commit).not.toHaveBeenCalled(); }); it('tracks the latest launch and hides dismissed sessions until the next launch', () => { diff --git a/apps/web/src/app/services/external-playback.service.ts b/apps/web/src/app/services/external-playback.service.ts index 161848f00..4e389ee8b 100644 --- a/apps/web/src/app/services/external-playback.service.ts +++ b/apps/web/src/app/services/external-playback.service.ts @@ -1,4 +1,5 @@ -import { computed, Injectable, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { PlaybackHistoryGate } from '@iptvnator/services'; import { ExternalPlayerSession, PlayerContentInfo, @@ -8,6 +9,7 @@ import { providedIn: 'root', }) export class ExternalPlaybackService { + private readonly historyGate = inject(PlaybackHistoryGate); readonly activeSession = signal(null); private readonly dismissedSessionId = signal(null); @@ -102,6 +104,12 @@ export class ExternalPlaybackService { } private handleSessionUpdate(session: ExternalPlayerSession): void { + // MPV/VLC cannot report whether a live stream really plays, so a + // successful launch is what commits the deferred history write. + if (session.status === 'opened' || session.status === 'playing') { + this.historyGate.confirm([session.streamUrl]); + } + const current = this.activeSession(); const restoresCurrentReplacement = current?.id === session.restoredFromSessionId; diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 590e8bbfa..68bd585ea 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -1129,6 +1129,39 @@ This keeps: - series and VOD-as-series support intact - external MPV/VLC launches unchanged +## Recently Viewed Confirmation + +Selecting or resolving an item is not watching it. A channel, movie or series +becomes a recently viewed item — and with it the dashboard hero — only once +its stream has really played, so a stream that fails straight away never +reaches history. + +- Writers do not persist on selection. They hand the write to + `PlaybackHistoryGate` (`@iptvnator/services`) with `defer(keys, commit)`, + keyed by what the playback will be known by: the stream URL and, for M3U, + the host's `playbackSessionKey`. The Stalker resolver defers by the + resolved (possibly temporary) link; the persisted row still stores the + portal `cmd`, never that link. Writers capture the item and its playlist + when they defer, so navigating meanwhile cannot misfile it. +- `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl` + and `playback.streamUrl` once the owned engine's reported position has + advanced by 2 seconds (`PlaybackProgressConfirmation`). Steps above + 3 seconds (seeks, a resume jump, live-edge catch-up), stalls, pauses and + backwards jumps do not count; a report of a new stream never adds to the + progress of the previous one; an engine or format swap of the same stream + keeps its progress. The radio `AudioPlayerComponent` confirms its URL the + same way. +- MPV/VLC cannot report whether a live stream plays, so the Electron + `ExternalPlaybackService` confirms a session's `streamUrl` once it is + `opened` or `playing`; a launch that ends in `error` is not recorded. M3U + keeps recording on selection when MPV/VLC is the configured player. +- A confirmation commits every write deferred under any of its keys, once. + Unconfirmed writes are bounded (oldest dropped) and simply never commit. +- A committed write updates the source while the item keeps playing. Hosts + must not hand the player a new but identical playback for it: the M3U + host's `embeddedPlayback` also reads the playlist meta, so it is compared + by value — a new object would remount the engine and restart the stream. + ## Playback Position Saving The old dialog path saved playback positions from inside the removed Xtream diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 51f61c67c..995e2ba63 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -74,6 +74,9 @@ Render rules: 2. `hasPlaylists() === false` → render `` full-bleed. All rails and the hero are skipped. 3. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel. + An item enters recent history only after its stream has really played + (see "Recently Viewed Confirmation" in `embedded-inline-playback.md`), so + a channel that failed at once never becomes the hero. 4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden — there is no "empty widget" placeholder. 5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it 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 new file mode 100644 index 000000000..a94714dc5 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts @@ -0,0 +1,254 @@ +import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { of } from 'rxjs'; +import { EpgService } from '@iptvnator/epg/data-access'; +import { selectActivePlaylist } from '@iptvnator/m3u-state'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; +import { + DataService, + PlaybackHistoryGate, + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { + PlaylistMeta, + Settings, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component'; +import { + dataServiceMock, + epgServiceMock, + epgUrlSetting, + epgViewMode, + externalSession, + player, + playlistId, + playlistsServiceMock, + routerMock, + sampleChannel, + showCaptions, + storeMock, + stripCountryPrefix, + syncStoreState, + translateServiceProvider, +} from './video-player.spec-harness'; + +jest.unstable_mockModule('video.js', () => ({ + default: jest.fn(), +})); +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +/** + * When a selected M3U channel becomes a recently viewed item (and with it + * the dashboard hero). Kept apart from `video-player.component.spec.ts`, + * which sits at the spec line budget; the template is reduced to nothing, + * because the players themselves are stood in for by the history gate. + */ +describe('VideoPlayerComponent — recently viewed history', () => { + let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent; + let fixture: ComponentFixture; + let component: VideoPlayerComponentInstance; + let gate: PlaybackHistoryGate; + const activePlaylist = signal | null>(null); + const harnessSelectSignal = storeMock.selectSignal.getMockImplementation(); + + beforeAll(async () => { + ({ VideoPlayerComponent } = await import('./video-player.component')); + }); + + beforeEach(async () => { + syncStoreState(null); + playlistId.set('playlist-1'); + player.set(VideoPlayer.VideoJs); + showCaptions.set(false); + stripCountryPrefix.set(false); + externalSession.set(null); + storeMock.dispatch.mockClear(); + activePlaylist.set({ _id: 'playlist-1', recentlyViewed: [] }); + storeMock.selectSignal.mockImplementation((selector: unknown) => + selector === selectActivePlaylist + ? activePlaylist + : harnessSelectSignal?.(selector) + ); + playlistsServiceMock.addM3uRecentlyViewed.mockClear(); + + await TestBed.configureTestingModule({ + imports: [VideoPlayerComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({ id: playlistId(), view: 'all' }), + queryParams: of({}), + snapshot: { + data: { layout: 'workspace' }, + queryParams: {}, + }, + }, + }, + { provide: Router, useValue: routerMock }, + { provide: Store, useValue: storeMock }, + translateServiceProvider, + { provide: DataService, useValue: dataServiceMock }, + { + provide: RuntimeCapabilitiesService, + useValue: { + supportsEpg: false, + isElectron: false, + supportsRemoteControl: false, + }, + }, + { provide: PlaylistsService, useValue: playlistsServiceMock }, + { provide: EpgService, useValue: epgServiceMock }, + { + provide: PlaylistContextFacade, + useValue: { resolvedPlaylistId: playlistId }, + }, + { + provide: TmdbEnrichmentService, + useValue: { isEnabled: () => false }, + }, + { + provide: SettingsStore, + useValue: { + player, + showCaptions, + stripCountryPrefix, + m3uVodDetails: signal(true), + resolvedEpgViewMode: epgViewMode, + resolvedEpgOffsetMinutes: signal(0), + epgUrl: epgUrlSetting, + }, + }, + { + provide: StorageMap, + useValue: { + get: jest.fn(() => + of({ player: player() } as Partial) + ), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: externalSession }, + }, + ], + }) + .overrideComponent(VideoPlayerComponent, { + set: { + imports: [], + template: + '', + }, + }) + .compileComponents(); + + gate = TestBed.inject(PlaybackHistoryGate); + fixture = TestBed.createComponent(VideoPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture?.destroy(); + storeMock.selectSignal.mockImplementation(harnessSelectSignal); + }); + + function select(channel = sampleChannel): void { + syncStoreState(channel); + fixture.detectChanges(); + } + + it('records an inline channel only once the player confirms it plays', () => { + select(); + + // Selected, but the stream has not played yet (or failed at once). + expect( + playlistsServiceMock.addM3uRecentlyViewed + ).not.toHaveBeenCalled(); + + gate.confirm([component.playbackSessionKey()]); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith( + 'playlist-1', + expect.objectContaining({ + source: 'm3u', + url: sampleChannel.url, + title: 'Sample TV', + }) + ); + }); + + it('keeps the playing source when recording updates the playlist', () => { + select(); + const playback = component.embeddedPlayback(); + expect(playback).not.toBeNull(); + + // What the confirmed write dispatches back into the store. + activePlaylist.set({ + _id: 'playlist-1', + recentlyViewed: [{ source: 'm3u', id: sampleChannel.url } as never], + }); + fixture.detectChanges(); + + // A new source object would remount the engine and restart the + // stream right after its first two seconds. + expect(component.embeddedPlayback()).toBe(playback); + }); + + it('never records a channel whose stream did not play', () => { + select(); + select({ + ...sampleChannel, + id: 'channel-2', + url: 'http://localhost/second.m3u8', + name: 'Second TV', + }); + + gate.confirm(['http://localhost/second.m3u8']); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( + 1 + ); + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith( + 'playlist-1', + expect.objectContaining({ title: 'Second TV' }) + ); + }); + + it('records a radio station once the audio player confirms its URL', () => { + const radio = { ...sampleChannel, radio: 'true' }; + select(radio); + + expect( + playlistsServiceMock.addM3uRecentlyViewed + ).not.toHaveBeenCalled(); + + gate.confirm([radio.url]); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( + 1 + ); + }); + + it('records on selection with an external player, which cannot confirm', () => { + player.set(VideoPlayer.MPV); + fixture.detectChanges(); + + select(); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( + 1 + ); + }); +}); 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 7148734de..832c8572c 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 @@ -114,6 +114,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util'; import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components'; import { DataService, + PlaybackHistoryGate, PlaylistsService, RecordingsService, RuntimeCapabilitiesService, @@ -237,6 +238,7 @@ export class VideoPlayerComponent private readonly hostElement = inject(ElementRef); private readonly dataService = inject(DataService); private readonly playlistsService = inject(PlaylistsService); + private readonly historyGate = inject(PlaybackHistoryGate); private readonly playlistContext = inject(PlaylistContextFacade); private readonly router = inject(Router); private readonly runtime = inject(RuntimeCapabilitiesService); @@ -485,7 +487,17 @@ export class VideoPlayerComponent epgParams: '', } as Channel; }); - readonly embeddedPlayback = computed(() => { + /** + * Compared by value: it also reads the playlist meta, which changes while + * a channel plays (the recently viewed write, a favourite toggle). A new + * but identical object would hand the player a new source and restart + * the stream. + */ + readonly embeddedPlayback = computed( + () => this.resolveEmbeddedPlayback(), + { equal: (a, b) => JSON.stringify(a) === JSON.stringify(b) } + ); + private resolveEmbeddedPlayback(): ResolvedPortalPlayback | null { const activeChannel = this.activeChannel(); const playbackTarget = this.playbackChannel(); @@ -528,7 +540,7 @@ export class VideoPlayerComponent // — extract lazily so they work without a re-import. drm: playbackTarget.drm ?? extractDrmFromRaw(playbackTarget.raw), }; - }); + } readonly sidebarStorageKey = computed(() => this.activeView() === 'groups' ? M3U_GROUPS_SIDEBAR_STORAGE_KEY @@ -842,7 +854,9 @@ export class VideoPlayerComponent } this.lastRecordedRecentKey = nextKey; - void this.persistRecentlyViewedChannel(playlistId, activeChannel); + untracked(() => + this.recordRecentlyViewedChannel(playlistId, activeChannel) + ); }); effect(() => { @@ -1198,6 +1212,29 @@ export class VideoPlayerComponent ); } + /** + * Inline playback (video, radio, movie detail) records the channel only + * once it has really played, so a stream that fails right away never + * reaches history or the dashboard hero. MPV/VLC cannot report that for + * a live stream, so they keep recording on selection. + */ + private recordRecentlyViewedChannel( + playlistId: string, + channel: Channel + ): void { + const record = () => + void this.persistRecentlyViewedChannel(playlistId, channel); + if (channel.radio !== 'true' && !this.shouldShowInlinePlayer(channel)) { + record(); + return; + } + + this.historyGate.defer( + [this.playbackSessionKey(), channel.url], + record + ); + } + private async persistRecentlyViewedChannel( playlistId: string, channel: Channel diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts index aa91b7fe9..ac43d9ab1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts @@ -8,6 +8,7 @@ import { PORTAL_PLAYER, UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; +import { PlaybackHistoryGate } from '@iptvnator/services'; import { ElectronStreamHeadersService } from '@iptvnator/ui/playback'; import { UnifiedLiveTimeshift } from './unified-live-catchup'; import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; @@ -71,6 +72,7 @@ export function createUnifiedLiveSelection(options: { const recentData = inject(UnifiedRecentDataService); const streamHeaders = inject(ElectronStreamHeadersService); const portalPlayer = inject(PORTAL_PLAYER); + const historyGate = inject(PlaybackHistoryGate); /** Stream URL of the radio playback whose header override this configured. */ let radioHeaderScopeUrl: string | null = null; @@ -119,6 +121,20 @@ export function createUnifiedLiveSelection(options: { }); }; + const recordLivePlayback = async ( + item: UnifiedCollectionItem, + generation: number + ): Promise => { + try { + const updatedItem = await recentData.recordLivePlayback(item); + if (generation === options.generation.current()) { + options.onItemPlayed(updatedItem); + } + } catch { + // Keep playback/EPG visible even if history persistence fails. + } + }; + const close = (): void => { options.generation.next(); pendingActivation = null; @@ -223,14 +239,12 @@ export function createUnifiedLiveSelection(options: { void portalPlayer.openResolvedPlayback(detail.playback); } - try { - const updatedItem = await recentData.recordLivePlayback(item); - if (generation === options.generation.current()) { - options.onItemPlayed(updatedItem); - } - } catch { - // Keep playback/EPG visible even if history persistence fails. - } + // Selecting a channel is not watching it: the row moves to the + // top of Recently Viewed once its stream has really played. + historyGate.defer( + [detail.playback.streamUrl], + () => void recordLivePlayback(item, generation) + ); if ( generation === options.generation.current() && diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 1ef25911a..5c6844f4d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -26,7 +26,11 @@ import { } from '@iptvnator/ui/epg'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ResizableDirective } from '@iptvnator/ui/components'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + PlaybackHistoryGate, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { EpgItem, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces'; import { PORTAL_PLAYER, @@ -157,6 +161,12 @@ describe('UnifiedLiveTabComponent', () => { window.electron = originalElectron; }); + /** What the mounted player reports once the stream has really played. */ + async function playStream(streamUrl: string): Promise { + TestBed.inject(PlaybackHistoryGate).confirm([streamUrl]); + await fixture.whenStable(); + } + it('renders controlled M3U EPG and records recent history on selection', async () => { const item = buildLiveItem('m3u'); streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ @@ -200,6 +210,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/m3u.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalledWith( item, @@ -658,6 +670,8 @@ describe('UnifiedLiveTabComponent', () => { streamUrl: 'https://example.com/m3u.m3u8', }) ); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/m3u.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalled(); @@ -711,6 +725,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/radio.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); expect( @@ -781,6 +797,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/jazz.mp3'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); expect( @@ -840,6 +858,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/xtream.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); const timeline = fixture.debugElement.query( By.directive(StubEpgTimelineComponent) @@ -873,6 +893,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/stalker.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); const timeline = fixture.debugElement.query( By.directive(StubEpgTimelineComponent) @@ -896,6 +918,10 @@ describe('UnifiedLiveTabComponent', () => { component.autoOpenHandled, 'emit' ); + // Like the collection page: a handled auto-open is cleared. + component.autoOpenHandled.subscribe(() => + fixture.componentRef.setInput('autoOpenItem', null) + ); streamResolver.resolveLiveDetail.mockReturnValue(pendingDetail.promise); recentData.recordLivePlayback.mockResolvedValue({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index ce6850aba..97c68a535 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -4,7 +4,11 @@ import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; -import { DataService, PlaylistsService } from '@iptvnator/services'; +import { + DataService, + PlaybackHistoryGate, + PlaylistsService, +} from '@iptvnator/services'; import { of } from 'rxjs'; import { PlaylistMeta, @@ -13,6 +17,11 @@ import { import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerPlayer } from './with-stalker-player.feature'; +/** What the player does once the resolved stream has really played. */ +function confirmPlayback(playback: { streamUrl: string }): void { + TestBed.inject(PlaybackHistoryGate).confirm([playback.streamUrl]); +} + jest.mock('@iptvnator/portal/shared/util', () => ({ ...jest.requireActual('@iptvnator/portal/shared/util'), createLogger: () => ({ @@ -160,6 +169,7 @@ describe('withStalkerPlayer', () => { 'Movie Title', 'thumb.jpg' ); + confirmPlayback(playback); expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith( 1, @@ -225,6 +235,7 @@ describe('withStalkerPlayer', () => { 1, 3000001 ); + confirmPlayback(playback); expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith( PLAYLIST._id, @@ -266,6 +277,7 @@ describe('withStalkerPlayer', () => { logo: 'jazz.png', category_id: '4001', }); + confirmPlayback(playback); expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith( @@ -372,6 +384,42 @@ describe('withStalkerPlayer', () => { category_id: '1001', }; + it('records a resolved channel only once its stream has played', async () => { + store.setSelectedContentType('itv'); + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=first' }, + }); + const failed = await store.resolveItvPlayback({ + ...CHANNEL, + use_http_tmp_link: '1', + }); + + // A link that never plays (dead stream, player error) is not a view. + expect( + playlistService.addPortalRecentlyViewed + ).not.toHaveBeenCalled(); + + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=second' }, + }); + const played = await store.resolveItvPlayback({ + ...CHANNEL, + use_http_tmp_link: '1', + }); + confirmPlayback(played); + + expect(failed.streamUrl).not.toBe(played.streamUrl); + expect( + playlistService.addPortalRecentlyViewed + ).toHaveBeenCalledTimes(1); + expect( + playlistService.addPortalRecentlyViewed + ).toHaveBeenCalledWith( + PLAYLIST._id, + expect.objectContaining({ id: '10001', cmd: CHANNEL.cmd }) + ); + }); + it('plays an unflagged ITV channel straight from its static cmd', async () => { store.setSelectedContentType('itv'); @@ -520,7 +568,9 @@ describe('withStalkerPlayer', () => { dataService.sendIpcEvent.mockResolvedValueOnce(response); } - await store.resolveItvPlayback({ ...CHANNEL, ...flags }); + confirmPlayback( + await store.resolveItvPlayback({ ...CHANNEL, ...flags }) + ); expect( playlistService.addPortalRecentlyViewed diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 7d43ce4c1..9f5dd9f8d 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -4,7 +4,11 @@ import { signalStoreFeature, withMethods } from '@ngrx/signals'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PORTAL_PLAYER, createLogger } from '@iptvnator/portal/shared/util'; -import { DataService, PlaylistsService } from '@iptvnator/services'; +import { + DataService, + PlaybackHistoryGate, + PlaylistsService, +} from '@iptvnator/services'; import { PlaylistMeta, ResolvedPortalPlayback, @@ -60,7 +64,8 @@ export function withStalkerPlayer() { portalRepair = inject(StalkerPortalRepairService), snackBar = inject(MatSnackBar), translate = inject(TranslateService), - ngrxStore = inject(Store) + ngrxStore = inject(Store), + historyGate = inject(PlaybackHistoryGate) ) => { const storeState = store as typeof store & StalkerPlayerFeatureStoreContract; @@ -122,7 +127,15 @@ export function withStalkerPlayer() { storeState.selectedContentType() ); + /** + * Resolving a link is not watching it: the write waits until + * the player (inline ≥2 s of progress, or a launched MPV/VLC) + * confirms `streamUrl`. The item and its portal are captured + * now, so switching portal or section meanwhile cannot + * misfile it. + */ const recordRecentlyViewed = ( + streamUrl: string, item: StalkerPlayableItem | null | undefined, cmd?: string, cover?: string, @@ -139,13 +152,12 @@ export function withStalkerPlayer() { cover, title ); - - if (typeof storeState.addToRecentlyViewed === 'function') { - storeState.addToRecentlyViewed(recentItem); - return; - } - - persistRecentlyViewed(playlistId, recentItem); + historyGate.defer([streamUrl], () => + persistRecentlyViewed(playlistId, { + ...recentItem, + added_at: Date.now(), + }) + ); }; const resolveVodPlaybackInternal = async ( @@ -157,9 +169,7 @@ export function withStalkerPlayer() { startTime?: number ): Promise => { const item = storeState.selectedItem() as - | StalkerPlayableItem - | null - | undefined; + StalkerPlayableItem | null | undefined; let cmdToUse = cmd ?? item?.cmd; if (!cmdToUse) { @@ -195,7 +205,13 @@ export function withStalkerPlayer() { } ); - recordRecentlyViewed(item, cmd, thumbnail, title); + recordRecentlyViewed( + streamUrl, + item, + cmd, + thumbnail, + title + ); const isEpisode = episodeNum !== undefined || episodeId !== undefined; @@ -285,6 +301,7 @@ export function withStalkerPlayer() { const portalOrigin = getStalkerPortalOrigin(playlist); recordRecentlyViewed( + streamUrl, item, item.cmd, item.logo ?? item.cover, @@ -350,6 +367,7 @@ export function withStalkerPlayer() { ); recordRecentlyViewed( + streamUrl, item, item.cmd, item.logo ?? item.cover, diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts index d8b238dd3..882e93d16 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts @@ -59,10 +59,6 @@ export interface StalkerSeriesFeatureStoreContract vodSeriesSeasonsResource: ResourceState; } -export interface StalkerRecentCallbackStoreContract { - addToRecentlyViewed?: (item: StalkerRecentlyViewedItem) => void; -} - export type StalkerRecentlyViewedItem = StalkerPortalItem & { id: string | number; title: string; @@ -74,8 +70,7 @@ export interface StalkerPlayerFeatureStoreContract Pick< StalkerSelectionStoreContract, 'selectedContentType' | 'selectedItem' - >, - StalkerRecentCallbackStoreContract {} + > {} export interface StalkerEpgFeatureStoreContract extends diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 07078021d..cfb30358d 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -36,6 +36,7 @@ import { resolveSeriesPlaybackEpisodeState, type SeriesPlaybackEpisodeState, } from '@iptvnator/ui/playback'; +import { injectXtreamRecentHistory } from '../xtream-recent-history'; import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token'; import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state'; import { @@ -66,6 +67,7 @@ export class SerialDetailsPlaybackService { ); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly recordRecentItem = injectXtreamRecentHistory(); private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET); private readonly seasonWatch = inject(SerialDetailsSeasonWatchService); @@ -209,9 +211,12 @@ export class SerialDetailsPlaybackService { return; } - this.addToRecentlyViewed(this.route.snapshot.params.serialId); - const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode); + this.recordRecentItem(streamUrl, { + xtreamId: this.route.snapshot.params.serialId, + contentType: 'series', + backdropUrl: selectedItem.info?.backdrop_path?.[0], + }); const contentInfo: PlayerContentInfo = { playlistId: playlist.id, contentXtreamId: Number(episode.id), @@ -404,15 +409,6 @@ export class SerialDetailsPlaybackService { return this.bindings()?.selectedItem() ?? null; } - private addToRecentlyViewed(xtreamId: number): void { - this.xtreamStore.addRecentItem({ - xtreamId, - contentType: 'series', - playlist: this.xtreamStore.currentPlaylist, - backdropUrl: this.selectedItem()?.info?.backdrop_path?.[0], - }); - } - private startPlayback( playback: ResolvedPortalPlayback, episodeState: SeriesPlaybackEpisodeState | null 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 518bdafba..47510bac3 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 @@ -25,7 +25,10 @@ import { } from '@iptvnator/portal/shared/util'; import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { + PlaybackHistoryGate, + PlaybackPositionRuntimeBridgeService, +} from '@iptvnator/services'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; import { BehaviorSubject, EMPTY, of } from 'rxjs'; @@ -564,12 +567,20 @@ describe('SerialDetailsComponent', () => { expect(constructEpisodeStreamUrl).toHaveBeenCalledWith( expect.objectContaining({ id: '1001' }) ); + // The series is a recent view only once the episode has played. + expect(addRecentItem).not.toHaveBeenCalled(); + TestBed.inject(PlaybackHistoryGate).confirm([ + 'http://xtream.example/series/1001.mp4', + ]); expect(addRecentItem).toHaveBeenCalledWith({ xtreamId: '103', contentType: 'series', - playlist: currentPlaylist, + playlist: expect.any(Function), backdropUrl: undefined, }); + expect(addRecentItem.mock.calls[0][0].playlist()).toEqual( + currentPlaylist() + ); expect(openResolvedPlayback).toHaveBeenCalledWith( expect.objectContaining({ streamUrl: 'http://xtream.example/series/1001.mp4', @@ -923,7 +934,10 @@ describe('SerialDetailsComponent', () => { SerialDetailsPlaybackService ); const snackBar = TestBed.inject(MatSnackBar); - const seasonPosition = (contentXtreamId: number, episodeNumber: number) => ({ + const seasonPosition = ( + contentXtreamId: number, + episodeNumber: number + ) => ({ playlistId: 'xtream-1', contentXtreamId, contentType: 'episode' as const, @@ -958,9 +972,9 @@ describe('SerialDetailsComponent', () => { expect.objectContaining({ contentXtreamId: 1002 }), ]); expect(savePlaybackPosition).not.toHaveBeenCalled(); - expect( - playbackService.episodePlaybackPositions().get(1001) - ).toEqual(expect.objectContaining({ positionSeconds: 1200 })); + expect(playbackService.episodePlaybackPositions().get(1001)).toEqual( + expect.objectContaining({ positionSeconds: 1200 }) + ); expect( playbackService.episodePlaybackPositions().get(1002) ).toBeDefined(); @@ -1065,9 +1079,7 @@ describe('SerialDetailsComponent', () => { const consoleError = jest .spyOn(console, 'error') .mockImplementation(() => undefined); - savePlaybackPositionsBatch.mockRejectedValue( - new Error('batch failed') - ); + savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed')); fixture.detectChanges(); await fixture.whenStable(); 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 63c39333f..31586ddda 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 @@ -6,7 +6,10 @@ import { PORTAL_PLAYER, } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { + PlaybackHistoryGate, + PlaybackPositionRuntimeBridgeService, +} from '@iptvnator/services'; import type { PlaybackPositionData, PlayerContentInfo, @@ -147,6 +150,34 @@ describe('VodDetailsPlaybackService — external session ownership', () => { }); }); + it('records the movie as recently viewed only once its stream played', async () => { + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/broken.mkv', + title: 'Broken source', + }); + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/route.mkv', + title: 'Working source', + }); + currentPlaylist.set({ id: 'playlist-switched-meanwhile' }); + + expect(addRecentItem).not.toHaveBeenCalled(); + + TestBed.inject(PlaybackHistoryGate).confirm([ + 'https://example.com/route.mkv', + ]); + + expect(addRecentItem).toHaveBeenCalledTimes(1); + const [recentItem] = addRecentItem.mock.calls[0]; + expect(recentItem).toEqual( + expect.objectContaining({ + xtreamId: ROUTE_VOD_ID, + contentType: 'movie', + }) + ); + expect(recentItem.playlist()).toEqual({ id: ROUTE_PLAYLIST }); + }); + it('owns a session launched for the route’s own stream', () => { activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); @@ -341,6 +372,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => { contentType: 'vod', }, }); + TestBed.inject(PlaybackHistoryGate).confirm([ + 'https://example.com/alt.mkv', + ]); expect(addRecentItem).toHaveBeenCalled(); }); 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 62529c89f..8a8967df8 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 @@ -38,6 +38,7 @@ import { createExternalLaunchOwner, startRouteOwnedPlayback, } from './vod-details-external-launch-owner'; +import { injectXtreamRecentHistory } from '../xtream-recent-history'; import { settleOwnedExternalLaunch } from './vod-details-external-launch'; import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; import { isResumablePosition } from './vod-primary-action-position'; @@ -68,6 +69,7 @@ export class VodDetailsPlaybackService { private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly recordRecentItem = injectXtreamRecentHistory(); private readonly playbackPositionBridge = inject( PlaybackPositionRuntimeBridgeService ); @@ -377,15 +379,6 @@ export class VodDetailsPlaybackService { this.positionLoaded.set(true); } - private addToRecentlyViewed(): void { - this.xtreamStore.addRecentItem({ - xtreamId: this.bindings()?.vodId() ?? NaN, - contentType: 'movie', - playlist: this.xtreamStore.currentPlaylist, - backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0], - }); - } - /** * The single inline-vs-external fork. Public so multi-source can switch * the playing source through exactly the same path a normal Play takes — @@ -469,7 +462,11 @@ export class VodDetailsPlaybackService { } // Same movie, different source: still a view. - this.addToRecentlyViewed(); + this.recordRecentItem(playback.streamUrl, { + xtreamId: this.bindings()?.vodId() ?? NaN, + contentType: 'movie', + backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0], + }); return await this.applyPlayback(playback, isCurrent); } finally { this.pendingStart.settle(startId); diff --git a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts new file mode 100644 index 000000000..5a182f6b1 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -0,0 +1,33 @@ +import { inject, signal } from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { PlaybackHistoryGate } from '@iptvnator/services'; + +export interface XtreamRecentItemRequest { + readonly xtreamId: number | string; + readonly contentType: 'movie' | 'series'; + readonly backdropUrl?: string; +} + +/** + * 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. The playlist is captured when playback starts, so navigating to + * another one meanwhile cannot misfile the item. + * + * Must run in an injection context. + */ +export function injectXtreamRecentHistory(): ( + streamUrl: string, + request: XtreamRecentItemRequest +) => void { + const gate = inject(PlaybackHistoryGate); + const store = inject(XtreamStore); + + return (streamUrl, request) => { + const playlist = signal(store.currentPlaylist()).asReadonly(); + gate.defer([streamUrl], () => + store.addRecentItem({ ...request, playlist }) + ); + }; +} diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index cf052bf34..987fc1118 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/database-electron.service'; export * from './lib/downloads.service'; export * from './lib/recordings.service'; export * from './lib/playback-position-runtime-bridge.service'; +export * from './lib/playback-history-gate.service'; export * from './lib/playback-position.service'; export * from './lib/playlist-delete-cleanup.token'; export * from './lib/playlist-delete-action.service'; diff --git a/libs/services/src/lib/playback-history-gate.service.spec.ts b/libs/services/src/lib/playback-history-gate.service.spec.ts new file mode 100644 index 000000000..84b7b109c --- /dev/null +++ b/libs/services/src/lib/playback-history-gate.service.spec.ts @@ -0,0 +1,100 @@ +import { PlaybackHistoryGate } from './playback-history-gate.service'; + +describe('PlaybackHistoryGate', () => { + let gate: PlaybackHistoryGate; + + beforeEach(() => { + gate = new PlaybackHistoryGate(); + }); + + it('holds a write until one of its keys is confirmed', () => { + const commit = jest.fn(); + gate.defer(['http://stream/1', 'live:p1:c1'], commit); + + expect(commit).not.toHaveBeenCalled(); + + gate.confirm(['live:p1:c1']); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('never commits a write whose stream is not confirmed', () => { + const failed = jest.fn(); + const played = jest.fn(); + gate.defer(['http://stream/failed'], failed); + gate.defer(['http://stream/played'], played); + + gate.confirm(['http://stream/played']); + + expect(failed).not.toHaveBeenCalled(); + expect(played).toHaveBeenCalledTimes(1); + }); + + it('commits each confirmed write once', () => { + const commit = jest.fn(); + gate.defer(['http://stream/1'], commit); + + gate.confirm(['http://stream/1']); + gate.confirm(['http://stream/1']); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('commits every writer deferred under the same key', () => { + const first = jest.fn(); + const second = jest.fn(); + gate.defer(['http://stream/1'], first); + gate.defer(['http://stream/1'], second); + + gate.confirm(['http://stream/1']); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('commits immediately when a write has no usable key', () => { + const commit = jest.fn(); + + gate.defer([undefined, null, ' '], commit); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('ignores confirmations without usable keys', () => { + const commit = jest.fn(); + gate.defer(['http://stream/1'], commit); + + gate.confirm([undefined, '']); + + expect(commit).not.toHaveBeenCalled(); + }); + + it('drops the oldest unconfirmed writes beyond its bound', () => { + const oldest = jest.fn(); + gate.defer(['http://stream/oldest'], oldest); + for (let index = 0; index < 20; index += 1) { + gate.defer([`http://stream/${index}`], jest.fn()); + } + + gate.confirm(['http://stream/oldest']); + + expect(oldest).not.toHaveBeenCalled(); + }); + + it('keeps committing the other writes when one throws', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const failing = jest.fn(() => { + throw new Error('db down'); + }); + const healthy = jest.fn(); + gate.defer(['http://stream/1'], failing); + gate.defer(['http://stream/1'], healthy); + + gate.confirm(['http://stream/1']); + + expect(healthy).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); +}); diff --git a/libs/services/src/lib/playback-history-gate.service.ts b/libs/services/src/lib/playback-history-gate.service.ts new file mode 100644 index 000000000..a304d0bd7 --- /dev/null +++ b/libs/services/src/lib/playback-history-gate.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@angular/core'; + +/** Keys a history write can be confirmed by: stream URLs or session keys. */ +export type PlaybackHistoryKeys = readonly (string | null | undefined)[]; + +interface PendingHistoryWrite { + readonly keys: ReadonlySet; + readonly commit: () => void; +} + +/** + * Upper bound on unconfirmed writes. Each click on a channel that never + * plays leaves one behind; the oldest is dropped rather than kept forever. + */ +const MAX_PENDING_HISTORY_WRITES = 20; + +/** + * Holds "recently viewed" writes back until the stream really plays. + * + * The code that resolves a channel or movie is not the code that plays it: + * a Stalker link is resolved in the store, played by whichever view mounts + * the player, or handed to MPV/VLC. Writers therefore `defer` the write under + * the keys the playback will be known by (its stream URL, or a playback + * session key), and whoever observes the playback `confirm`s those keys: + * the inline players once the stream has advanced for a couple of seconds, + * and the external-player session as soon as MPV/VLC is launched. A stream + * that fails before that point never reaches history or the dashboard hero. + * + * Several writers may defer under the same key; one confirmation commits + * all of them. A write whose keys are all empty cannot be confirmed and is + * committed immediately, as before this gate existed. + */ +@Injectable({ providedIn: 'root' }) +export class PlaybackHistoryGate { + private pending: PendingHistoryWrite[] = []; + + defer(keys: PlaybackHistoryKeys, commit: () => void): void { + const normalized = normalizeKeys(keys); + if (normalized.size === 0) { + runCommit(commit); + return; + } + + this.pending.push({ keys: normalized, commit }); + if (this.pending.length > MAX_PENDING_HISTORY_WRITES) { + this.pending.shift(); + } + } + + confirm(keys: PlaybackHistoryKeys): void { + const normalized = normalizeKeys(keys); + if (normalized.size === 0 || this.pending.length === 0) { + return; + } + + const matched: PendingHistoryWrite[] = []; + const remaining: PendingHistoryWrite[] = []; + for (const write of this.pending) { + const matches = [...write.keys].some((key) => normalized.has(key)); + (matches ? matched : remaining).push(write); + } + this.pending = remaining; + matched.forEach((write) => runCommit(write.commit)); + } +} + +function normalizeKeys(keys: PlaybackHistoryKeys): ReadonlySet { + return new Set( + keys + .map((key) => key?.trim() ?? '') + .filter((key): key is string => key.length > 0) + ); +} + +function runCommit(commit: () => void): void { + try { + commit(); + } catch (error) { + // History is best effort: a failed write must not break playback or + // the other writes confirmed by the same stream. + console.error('Failed to record recently viewed item:', error); + } +} diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts index 6ff831007..a0fbee4bf 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; +import { PlaybackHistoryGate } from '@iptvnator/services'; import { AudioPlayerComponent } from './audio-player.component'; describe('AudioPlayerComponent', () => { @@ -176,6 +177,43 @@ describe('AudioPlayerComponent', () => { expect(store.dispatch).toHaveBeenCalledTimes(1); }); + it('records the station in history only after it has played', () => { + const audio = createComponent('https://example.com/station.mp3'); + const commit = jest.fn(); + TestBed.inject(PlaybackHistoryGate).defer( + ['https://example.com/station.mp3'], + commit + ); + const playTo = (position: number) => { + Object.defineProperty(audio, 'currentTime', { + configurable: true, + value: position, + }); + audio.dispatchEvent(new Event('timeupdate')); + }; + + playTo(0); + playTo(1); + expect(commit).not.toHaveBeenCalled(); + + playTo(2); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not record a station that never advances', () => { + const audio = createComponent('https://example.com/dead.mp3'); + const commit = jest.fn(); + TestBed.inject(PlaybackHistoryGate).defer( + ['https://example.com/dead.mp3'], + commit + ); + + audio.dispatchEvent(new Event('timeupdate')); + audio.dispatchEvent(new Event('timeupdate')); + + expect(commit).not.toHaveBeenCalled(); + }); + it('pauses the stream when the component is destroyed', () => { createComponent(); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index e49cc7ef7..116c2563e 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -22,6 +22,8 @@ import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; import { ChannelActions } from '@iptvnator/m3u-state'; +import { PlaybackHistoryGate } from '@iptvnator/services'; +import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation'; @Component({ selector: 'app-audio-player', @@ -156,7 +158,12 @@ import { ChannelActions } from '@iptvnator/m3u-state'; - + `, styleUrls: ['./audio-player.component.scss'], @@ -198,6 +205,11 @@ export class AudioPlayerComponent { private destroyRef = inject(DestroyRef); private hostEl = inject(ElementRef); private fallbackVolume = 1; + /** Commits the deferred "recently viewed" write once the station plays. */ + private readonly historyConfirmation = new PlaybackHistoryConfirmation({ + gate: inject(PlaybackHistoryGate), + keys: () => [this.url()], + }); constructor() { const saved = parseFloat(localStorage.getItem('volume') ?? '1'); @@ -268,6 +280,11 @@ export class AudioPlayerComponent { } } + onTimeUpdate(): void { + const audio = this.audioRef()?.nativeElement; + if (audio) this.historyConfirmation.record(audio.currentTime); + } + play() { const audio = this.audioRef()?.nativeElement; if (!audio) return; diff --git a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts new file mode 100644 index 000000000..8dd568a78 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts @@ -0,0 +1,59 @@ +import type { PlaybackHistoryKeys } from '@iptvnator/services'; +import { PlaybackHistoryConfirmation } from './playback-history-confirmation'; + +describe('PlaybackHistoryConfirmation', () => { + let keys: PlaybackHistoryKeys; + let revision: symbol; + let confirm: jest.Mock; + let confirmation: PlaybackHistoryConfirmation; + + const play = (...positions: number[]) => + positions.forEach((position) => confirmation.record(position)); + + beforeEach(() => { + keys = ['live:p1:a', 'http://stream/a']; + revision = Symbol('first'); + confirm = jest.fn(); + confirmation = new PlaybackHistoryConfirmation({ + gate: { confirm }, + keys: () => keys, + sourceRevision: () => revision, + }); + }); + + it('confirms the keys of the stream that played', () => { + play(0, 1, 2); + + expect(confirm).toHaveBeenCalledWith(['live:p1:a', 'http://stream/a']); + }); + + it('does not credit a new stream with the previous stream progress', () => { + play(0, 1.5); + keys = ['live:p1:b', 'http://stream/b']; + play(0, 0.5); + + expect(confirm).not.toHaveBeenCalled(); + + play(1, 2); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledWith(['live:p1:b', 'http://stream/b']); + }); + + it('confirms a returning stream again after another one played', () => { + play(0, 1, 2); + keys = ['live:p1:b', 'http://stream/b']; + play(0, 1, 2); + keys = ['live:p1:a', 'http://stream/a']; + play(0, 1, 2); + + expect(confirm).toHaveBeenCalledTimes(3); + }); + + it('keeps progress across an engine swap of the same stream', () => { + play(0, 1.5); + revision = Symbol('fallback'); + play(30, 30.5); + + expect(confirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts new file mode 100644 index 000000000..4819b6156 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts @@ -0,0 +1,48 @@ +import type { + PlaybackHistoryGate, + PlaybackHistoryKeys, +} from '@iptvnator/services'; +import { PlaybackProgressConfirmation } from './playback-progress-confirmation'; + +export interface PlaybackHistoryConfirmationOptions { + readonly gate: Pick; + /** Keys of what is playing now: session key and/or stream URLs. */ + readonly keys: () => PlaybackHistoryKeys; + /** + * Engine/source generation within the same keys (engine switch, live + * format fallback, reload). A change restarts the position clock but + * keeps the progress already seen. + */ + readonly sourceRevision?: () => unknown; +} + +/** + * Confirms a player's current stream to the {@link PlaybackHistoryGate} once + * it has really played. What is playing is re-read on every report rather + * than tracked by an effect, so a report of a new stream can never be added + * to the progress of the one before it. + */ +export class PlaybackHistoryConfirmation { + private identity: string | null = null; + private sourceRevision: unknown = null; + private readonly progress = new PlaybackProgressConfirmation(() => + this.options.gate.confirm(this.options.keys()) + ); + + constructor(private readonly options: PlaybackHistoryConfirmationOptions) {} + + record(position: number): void { + const identity = JSON.stringify(this.options.keys()); + const sourceRevision = this.options.sourceRevision?.() ?? null; + if (identity !== this.identity) { + this.identity = identity; + this.sourceRevision = sourceRevision; + this.progress.reset(); + } else if (sourceRevision !== this.sourceRevision) { + this.sourceRevision = sourceRevision; + this.progress.rebase(); + } + + this.progress.record(position); + } +} diff --git a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts new file mode 100644 index 000000000..3a8351a7f --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts @@ -0,0 +1,87 @@ +import { PlaybackProgressConfirmation } from './playback-progress-confirmation'; + +describe('PlaybackProgressConfirmation', () => { + let onConfirmed: jest.Mock; + let confirmation: PlaybackProgressConfirmation; + + const play = (...positions: number[]) => + positions.forEach((position) => confirmation.record(position)); + + beforeEach(() => { + onConfirmed = jest.fn(); + confirmation = new PlaybackProgressConfirmation(onConfirmed); + }); + + it('confirms once the stream has advanced for two seconds', () => { + play(0, 0.5, 1, 1.5); + expect(onConfirmed).not.toHaveBeenCalled(); + + play(2); + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('confirms whole-second position reports (embedded MPV)', () => { + play(10, 10, 11, 11, 12); + + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('confirms only once per stream', () => { + play(0, 1, 2, 3, 4, 5); + + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('does not confirm a stream that stalls at its first frames', () => { + play(0, 0.4, 0.4, 0.4, 0.4, 0.4); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('does not count a seek as watched time', () => { + // A VOD resume jumps straight to the saved position. + play(0, 1200, 1200.5); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('does not count backwards jumps', () => { + play(5, 4, 3, 2, 1); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('ignores non-finite positions', () => { + play(0, Number.NaN, Number.POSITIVE_INFINITY, 1); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('starts over after reset', () => { + play(0, 1.5); + confirmation.reset(); + play(100, 101); + + expect(onConfirmed).not.toHaveBeenCalled(); + + play(102); + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('confirms again for a new stream after reset', () => { + play(0, 1, 2); + confirmation.reset(); + play(0, 1, 2); + + expect(onConfirmed).toHaveBeenCalledTimes(2); + }); + + it('keeps the progress across a rebase but not the position', () => { + play(0, 1.5); + confirmation.rebase(); + // The new engine's clock starts elsewhere; that jump is not counted. + play(50, 50.5); + + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts new file mode 100644 index 000000000..c8ad01ade --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts @@ -0,0 +1,61 @@ +/** Seconds a stream must actually advance before it counts as watched. */ +export const PLAYBACK_CONFIRMATION_SECONDS = 2; + +/** + * Largest position step still counted as playback. Engines report every + * 250–500 ms, so anything larger is a seek (resume position, live-edge + * catch-up, ±10 s keys) rather than seconds the viewer has seen. + */ +const MAX_PLAYBACK_STEP_SECONDS = 3; + +/** + * Turns an engine's position reports into a single "this stream plays" + * signal. `playing` alone is not enough: a broken stream can fire it and + * stall or error a moment later, so only position that really advanced + * counts. Pauses, stalls, seeks and backwards jumps add nothing. + */ +export class PlaybackProgressConfirmation { + private lastPosition: number | null = null; + private progressedSeconds = 0; + private confirmed = false; + + constructor(private readonly onConfirmed: () => void) {} + + /** Starts over for a new stream. */ + reset(): void { + this.lastPosition = null; + this.progressedSeconds = 0; + this.confirmed = false; + } + + /** + * Keeps the progress but forgets the last position, for an engine or + * source swap within the same stream whose clock restarts elsewhere. + */ + rebase(): void { + this.lastPosition = null; + } + + record(position: number): void { + if (this.confirmed || !Number.isFinite(position)) { + return; + } + + const previous = this.lastPosition; + this.lastPosition = position; + if (previous === null) { + return; + } + + const step = position - previous; + if (step <= 0 || step > MAX_PLAYBACK_STEP_SECONDS) { + return; + } + + this.progressedSeconds += step; + if (this.progressedSeconds >= PLAYBACK_CONFIRMATION_SECONDS) { + this.confirmed = true; + this.onConfirmed(); + } + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts new file mode 100644 index 000000000..f0e5a098d --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts @@ -0,0 +1,191 @@ +import { + ComponentFixture, + DeferBlockBehavior, + TestBed, +} from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { TranslateModule } from '@ngx-translate/core'; +import { + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, +} from '@iptvnator/playback/util'; +import { + PlaybackHistoryGate, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import { + STORE_KEY, + VideoPlayer, + type ExternalPlayerSession, +} from '@iptvnator/shared/interfaces'; +import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; +import { of } from 'rxjs'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubFullscreenChannelPanelComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; +import { ElectronStreamHeadersService } from './electron-stream-headers.service'; +import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; + +jest.unstable_mockModule('video.js', () => ({ default: jest.fn() })); +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +describe('WebPlayerViewComponent playback history', () => { + let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; + let fixture: ComponentFixture; + let gate: PlaybackHistoryGate; + + beforeAll(async () => { + ({ WebPlayerViewComponent } = + await import('./web-player-view.component')); + }); + + beforeEach(async () => { + const externalSession = signal(null); + await TestBed.configureTestingModule({ + deferBlockBehavior: DeferBlockBehavior.Playthrough, + imports: [WebPlayerViewComponent, TranslateModule.forRoot()], + providers: [ + { + provide: StorageMap, + useValue: { + get: (key: string) => + of( + key === STORE_KEY.Settings + ? { player: VideoPlayer.VideoJs } + : undefined + ), + }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsManagedExternalPlayers: true }, + }, + { + provide: ElectronStreamHeadersService, + useValue: { apply: () => null, clear: jest.fn() }, + }, + { + provide: SettingsStore, + useValue: { + showCaptions: () => false, + webPlayerSharedControls: () => false, + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: externalSession, + visibleSession: externalSession, + dismissActiveSession: jest.fn(), + closeSession: jest.fn(), + }, + }, + ], + }) + .overrideComponent(WebPlayerViewComponent, { + set: { + imports: [ + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubFullscreenChannelPanelComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, + ], + // The diagnostic panel itself is not under test here. + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }, + }) + .compileComponents(); + + gate = TestBed.inject(PlaybackHistoryGate); + fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('streamUrl', 'https://example.com/a'); + fixture.componentRef.setInput('title', 'Channel A'); + fixture.componentRef.setInput('playbackSessionKey', 'live:p1:a'); + await render(); + }); + + afterEach(() => fixture.destroy()); + + it('records a channel once its stream has played for two seconds', () => { + const commit = jest.fn(); + gate.defer(['live:p1:a'], commit); + + playTo(0, 1); + expect(commit).not.toHaveBeenCalled(); + + playTo(2); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('confirms by stream URL for writers that only know the link', () => { + const commit = jest.fn(); + gate.defer(['https://example.com/a'], commit); + + playTo(0, 1, 2); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not record a channel whose stream fails before playing', () => { + const commit = jest.fn(); + gate.defer(['live:p1:a'], commit); + + playTo(0); + vjs().playbackIssue.emit({ + code: PlaybackDiagnosticCode.MediaDecodeError, + source: PlaybackDiagnosticSource.Vhs, + sourceUrl: 'https://example.com/a', + container: 'm3u8', + mimeType: 'application/x-mpegURL', + player: 'videojs', + audioCodecs: [], + videoCodecs: [], + }); + fixture.detectChanges(); + + expect(commit).not.toHaveBeenCalled(); + }); + + it('does not credit the next channel with the previous one', async () => { + const next = jest.fn(); + playTo(0, 1.5); + + fixture.componentRef.setInput('streamUrl', 'https://example.com/b'); + fixture.componentRef.setInput('playbackSessionKey', 'live:p1:b'); + gate.defer(['live:p1:b'], next); + await render(); + playTo(0, 0.5); + + expect(next).not.toHaveBeenCalled(); + + playTo(1, 2); + expect(next).toHaveBeenCalledTimes(1); + }); + + async function render(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + function playTo(...positions: number[]): void { + positions.forEach((currentTime) => + vjs().timeUpdate.emit({ currentTime, duration: 0 }) + ); + } + + function vjs(): StubVjsPlayerComponent { + return fixture.debugElement.query(By.directive(StubVjsPlayerComponent)) + .componentInstance as StubVjsPlayerComponent; + } +}); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 75be963e1..c55853208 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -22,7 +22,11 @@ import { type PlaybackRecommendationTarget, } from '@iptvnator/playback/util'; import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + PlaybackHistoryGate, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { VideoPlayer, type Channel, @@ -36,6 +40,7 @@ import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv- import { FullscreenChannelPanelComponent } from '../fullscreen-channel-panel/fullscreen-channel-panel.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; +import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation'; import { type PlayerMediaTitle, WEB_PLAYER_SHARED_CONTROLS, @@ -213,6 +218,16 @@ export class WebPlayerViewComponent implements OnDestroy { readonly resolvedIsLive = this.applicationState.isLive; readonly playbackSourceRevisionToken = this.applicationState.sourceRevision; readonly playbackApplicationToken = this.applicationState.token; + /** Commits deferred "recently viewed" writes once this stream plays. */ + private readonly historyConfirmation = new PlaybackHistoryConfirmation({ + gate: inject(PlaybackHistoryGate), + keys: () => [ + this.playbackSessionKey(), + this.streamUrl(), + this.playback()?.streamUrl, + ], + sourceRevision: () => this.playbackSourceRevisionToken(), + }); readonly playbackExternallyTransferable = computed(() => isPlaybackExternallyTransferable(this.resolvedPlayback()) ); @@ -377,6 +392,7 @@ export class WebPlayerViewComponent implements OnDestroy { } this.recoverySession.recordTimeUpdate(event, ownership.isLive); + this.historyConfirmation.record(event.currentTime); this.timeUpdate.emit(event); }