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/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 590e8bbfa..22753815b 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -1129,6 +1129,58 @@ 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/playback/data-access`) with + `defer(target, commit)`, where the target is what the playback will be + known by: the host's `playbackSessionKey` (M3U) and/or stream URLs. 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; an + Xtream write confirmed after a switch to another playlist (only a slow + MPV/VLC launch can) is saved to its own playlist without reloading the + store's recent list, which belongs to the other playlist by then. +- Matching: a write deferred with a session key is confirmed only by that + same key — the same URL in two playlists must not let playback in one + (inline, or in MPV/VLC) record a failed attempt in the other. Writes + without one (portal resolvers) match any confirmation of their stream URL. + The global live tab defers with its own playlist-scoped session key, which + also survives a switch to catch-up, when the row plays inline; a row that + goes to MPV/VLC defers by URL, the only thing that launch confirms. +- `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl` + and `playback.streamUrl` once the owned engine's reported position has + advanced by 2 seconds while playing (`PlaybackProgressConfirmation`). + Engines report `playing` (not paused, not seeking) with each time update, + so seeks of paused media do not count; neither do steps above 3 seconds + (seeks, a resume jump, live-edge catch-up), stalls or backwards jumps. 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 the same way (with the host's session key + when given). +- MPV/VLC cannot report whether a live stream plays, so the gate itself + subscribes to the Electron external-player session updates and confirms a + session's `streamUrl` once it is `opened` or `playing`; a launch that ends + in `error` is not recorded. (Subscribing in the gate, which the first + deferred write creates, keeps it off the initial bundle.) That + confirmation carries no session key, so an "Open in MPV/VLC" recovery + launch is also confirmed by the `WebPlayerViewComponent` that requested + it, under its own session key, once the launch has opened. M3U keeps + recording on selection when MPV/VLC is the configured player. +- A confirmation commits every write matching it, once. Unconfirmed writes + are bounded (oldest dropped) and simply never commit. The global live tab + moves a confirmed row to the top of an open Recently Viewed list even if + another row was selected meanwhile. +- 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/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 86daacfcb..2667ceebf 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -189,6 +189,14 @@ other utility projects, including shared interface contracts, while `ui-playback` and feature hosts may depend on it to render and execute session-local recovery actions. +`libs/playback/data-access` is the `playback-data-access` Nx project, +imported through `@iptvnator/playback/data-access` (tags `scope:shared`, +`domain:playback`, `type:data-access`). It owns renderer playback state that +store, feature and player layers share — currently `PlaybackHistoryGate`, +which holds "recently viewed" writes until playback is confirmed. It is kept +out of `@iptvnator/services` on purpose: that barrel ships in the initial +bundle, and this project is only reached from lazy player and portal code. + ## Project Tags Every Nx project keeps one tag from each family in `project.json`: diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index c06c43c94..352f8cb79 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -75,7 +75,10 @@ Render rules: full-bleed. All rails and the hero are skipped. 3. The hero (`lib-dashboard-hero`) renders when it has at least one slide; see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while - the first history load runs. + the first history load runs. 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 a hero slide. 4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden — there is no "empty widget" placeholder. 5. Hero slides prefer a stored `backdrop_url`, then the TMDB backdrop; when diff --git a/libs/playback/data-access/jest.config.ts b/libs/playback/data-access/jest.config.ts new file mode 100644 index 000000000..bb6df3689 --- /dev/null +++ b/libs/playback/data-access/jest.config.ts @@ -0,0 +1,22 @@ +export default { + displayName: 'playback-data-access', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/playback/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], + moduleFileExtensions: ['ts', 'js', 'html'], +}; diff --git a/libs/playback/data-access/project.json b/libs/playback/data-access/project.json new file mode 100644 index 000000000..263f324b9 --- /dev/null +++ b/libs/playback/data-access/project.json @@ -0,0 +1,21 @@ +{ + "name": "playback-data-access", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/playback/data-access/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:shared", "domain:playback", "type:data-access"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/playback/data-access/jest.config.ts", + "tsConfig": "libs/playback/data-access/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/playback/data-access/src/index.ts b/libs/playback/data-access/src/index.ts new file mode 100644 index 000000000..d91e2e8dd --- /dev/null +++ b/libs/playback/data-access/src/index.ts @@ -0,0 +1 @@ +export * from './lib/playback-history-gate.service'; diff --git a/libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts b/libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts new file mode 100644 index 000000000..230418cd0 --- /dev/null +++ b/libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts @@ -0,0 +1,209 @@ +import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import { PlaybackHistoryGate } from './playback-history-gate.service'; + +describe('PlaybackHistoryGate', () => { + let gate: PlaybackHistoryGate; + + beforeEach(() => { + gate = new PlaybackHistoryGate(); + }); + + it('holds a write until its playback is confirmed', () => { + const commit = jest.fn(); + gate.defer( + { sessionKey: 'live:p1:c1', streamUrls: ['http://stream/1'] }, + commit + ); + + expect(commit).not.toHaveBeenCalled(); + + gate.confirm({ sessionKey: '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({ streamUrls: ['http://stream/failed'] }, failed); + gate.defer({ streamUrls: ['http://stream/played'] }, played); + + gate.confirm({ streamUrls: ['http://stream/played'] }); + + expect(failed).not.toHaveBeenCalled(); + expect(played).toHaveBeenCalledTimes(1); + }); + + it('does not let the same URL played in another playlist confirm a write', () => { + // Playlist A's attempt failed; the same stream then plays in B. + const failedInA = jest.fn(); + const playedInB = jest.fn(); + gate.defer( + { sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] }, + failedInA + ); + gate.defer( + { sessionKey: 'live:b:c9', streamUrls: ['http://shared/1'] }, + playedInB + ); + + gate.confirm({ + sessionKey: 'live:b:c9', + streamUrls: ['http://shared/1'], + }); + + expect(failedInA).not.toHaveBeenCalled(); + expect(playedInB).toHaveBeenCalledTimes(1); + }); + + it('does not let a URL-only confirmation commit a write deferred with a session key', () => { + // Playlist A's attempt failed; the same URL then opens in MPV/VLC, + // whose app-wide session confirmation carries no session key. + const failedInA = jest.fn(); + gate.defer( + { sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] }, + failedInA + ); + + gate.confirm({ streamUrls: ['http://shared/1'] }); + + expect(failedInA).not.toHaveBeenCalled(); + }); + + it('matches writes without a session key by stream URL', () => { + const portalWrite = jest.fn(); + const externalWrite = jest.fn(); + gate.defer({ streamUrls: ['http://portal/tmp'] }, portalWrite); + gate.defer({ streamUrls: ['http://portal/vod'] }, externalWrite); + + // An inline player (with its own key) and an MPV/VLC session. + gate.confirm({ + sessionKey: 'live:portal:9', + streamUrls: ['http://portal/tmp'], + }); + gate.confirm({ streamUrls: ['http://portal/vod'] }); + + expect(portalWrite).toHaveBeenCalledTimes(1); + expect(externalWrite).toHaveBeenCalledTimes(1); + }); + + it('commits each confirmed write once', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['http://stream/1'] }, commit); + + gate.confirm({ streamUrls: ['http://stream/1'] }); + gate.confirm({ streamUrls: ['http://stream/1'] }); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('commits every writer deferred for the same playback', () => { + const first = jest.fn(); + const second = jest.fn(); + gate.defer({ streamUrls: ['http://stream/1'] }, first); + gate.defer({ streamUrls: ['http://stream/1'] }, second); + + gate.confirm({ streamUrls: ['http://stream/1'] }); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('commits immediately when a write has nothing to match on', () => { + const commit = jest.fn(); + + gate.defer({ sessionKey: ' ', streamUrls: [undefined, null] }, commit); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('ignores confirmations with nothing to match on', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['http://stream/1'] }, commit); + + gate.confirm({ streamUrls: [undefined, ''] }); + + expect(commit).not.toHaveBeenCalled(); + }); + + it('drops the oldest unconfirmed writes beyond its bound', () => { + const oldest = jest.fn(); + gate.defer({ streamUrls: ['http://stream/oldest'] }, oldest); + for (let index = 0; index < 20; index += 1) { + gate.defer({ streamUrls: [`http://stream/${index}`] }, jest.fn()); + } + + gate.confirm({ streamUrls: ['http://stream/oldest'] }); + + expect(oldest).not.toHaveBeenCalled(); + }); + + describe('MPV/VLC sessions', () => { + const originalElectron = window.electron; + let emit: (session: Partial) => void; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: { + onExternalPlayerSessionUpdate: ( + callback: (session: ExternalPlayerSession) => void + ) => { + emit = (session) => + callback({ + streamUrl: 'http://vod/1.mkv', + ...session, + } as ExternalPlayerSession); + return () => undefined; + }, + }, + }); + gate = new PlaybackHistoryGate(); + }); + + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: originalElectron, + }); + }); + + it('confirms a stream once its external player has opened', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit); + + emit({ status: 'launching' }); + expect(commit).not.toHaveBeenCalled(); + + emit({ status: 'opened' }); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not confirm a launch that failed', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit); + + emit({ status: 'launching' }); + emit({ status: 'error' }); + + expect(commit).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({ streamUrls: ['http://stream/1'] }, failing); + gate.defer({ streamUrls: ['http://stream/1'] }, healthy); + + gate.confirm({ streamUrls: ['http://stream/1'] }); + + expect(healthy).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); +}); diff --git a/libs/playback/data-access/src/lib/playback-history-gate.service.ts b/libs/playback/data-access/src/lib/playback-history-gate.service.ts new file mode 100644 index 000000000..a3ba9e75b --- /dev/null +++ b/libs/playback/data-access/src/lib/playback-history-gate.service.ts @@ -0,0 +1,130 @@ +import { Injectable } from '@angular/core'; + +/** What a playback is known by, for correlating a write with its playback. */ +export interface PlaybackHistoryTarget { + /** + * The playing host's `playbackSessionKey` (source and content scoped). + * A write deferred with one is only confirmed by that same key. + */ + readonly sessionKey?: string | null; + /** Stream URLs; what a write without a session key is matched by. */ + readonly streamUrls?: readonly (string | null | undefined)[]; +} + +interface NormalizedTarget { + readonly sessionKey: string | null; + readonly streamUrls: ReadonlySet; +} + +interface PendingHistoryWrite { + readonly target: NormalizedTarget; + 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 + * what the playback will be known by, and whoever observes the playback + * `confirm`s it: 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. + * + * A write deferred with a session key is only confirmed by the same key: the + * same stream URL can sit in two playlists, and playing it in one — inline + * or in MPV/VLC, whose app-wide confirmation knows only the URL — must not + * record a failed attempt in the other. Writers that cannot know the + * playing host's key (portal resolvers, collection tabs) defer by stream + * URL, which any confirmation of that URL matches. + * Several writers may defer for the same playback; one confirmation commits + * all of them. A write with nothing to match on cannot be confirmed and is + * committed immediately, as before this gate existed. + */ +@Injectable({ providedIn: 'root' }) +export class PlaybackHistoryGate { + private pending: PendingHistoryWrite[] = []; + + constructor() { + // MPV/VLC cannot report whether a live stream plays, so a launch + // that opened is the confirmation. Subscribed here rather than in + // the app-wide external-playback service so the gate stays off the + // initial bundle: it is created by the first deferred write, which + // always precedes the launch it waits for. + window.electron?.onExternalPlayerSessionUpdate?.((session) => { + if (session.status === 'opened' || session.status === 'playing') { + this.confirm({ streamUrls: [session.streamUrl] }); + } + }); + } + + defer(target: PlaybackHistoryTarget, commit: () => void): void { + const normalized = normalizeTarget(target); + if (!normalized.sessionKey && normalized.streamUrls.size === 0) { + runCommit(commit); + return; + } + + this.pending.push({ target: normalized, commit }); + if (this.pending.length > MAX_PENDING_HISTORY_WRITES) { + this.pending.shift(); + } + } + + confirm(target: PlaybackHistoryTarget): void { + const confirmed = normalizeTarget(target); + const matched: PendingHistoryWrite[] = []; + const remaining: PendingHistoryWrite[] = []; + for (const write of this.pending) { + (matchesTarget(write.target, confirmed) ? matched : remaining).push( + write + ); + } + this.pending = remaining; + matched.forEach((write) => runCommit(write.commit)); + } +} + +function matchesTarget( + write: NormalizedTarget, + confirmed: NormalizedTarget +): boolean { + if (write.sessionKey) { + return write.sessionKey === confirmed.sessionKey; + } + return [...write.streamUrls].some((url) => confirmed.streamUrls.has(url)); +} + +function normalizeTarget(target: PlaybackHistoryTarget): NormalizedTarget { + return { + sessionKey: normalizeKey(target.sessionKey), + streamUrls: new Set( + (target.streamUrls ?? []) + .map(normalizeKey) + .filter((url): url is string => url !== null) + ), + }; +} + +function normalizeKey(key: string | null | undefined): string | null { + const trimmed = key?.trim() ?? ''; + return trimmed.length > 0 ? trimmed : null; +} + +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/playback/data-access/src/test-setup.ts b/libs/playback/data-access/src/test-setup.ts new file mode 100644 index 000000000..1ca0136b9 --- /dev/null +++ b/libs/playback/data-access/src/test-setup.ts @@ -0,0 +1,9 @@ +// Keep Zone's fakeAsync helpers while preserving the Angular 21 test scheduler. +import 'zone.js'; +import 'zone.js/testing'; +import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless'; + +setupZonelessTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/playback/data-access/tsconfig.json b/libs/playback/data-access/tsconfig.json new file mode 100644 index 000000000..8fcb75804 --- /dev/null +++ b/libs/playback/data-access/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve", + "ignoreDeprecations": "6.0" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/playback/data-access/tsconfig.lib.json b/libs/playback/data-access/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/playback/data-access/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/playback/data-access/tsconfig.spec.json b/libs/playback/data-access/tsconfig.spec.json new file mode 100644 index 000000000..71dd0db03 --- /dev/null +++ b/libs/playback/data-access/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDecoratorMetadata": false, + "outDir": "../../../dist/out-tsc", + "module": "preserve", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "bundler" + }, + "files": ["src/test-setup.ts", "../../../global.d.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} 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..889b9f0ed --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts @@ -0,0 +1,286 @@ +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, + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +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: [] }); + // The harness has no playlist meta; the history write updates it. + storeMock.selectSignal.mockImplementation(((selector: unknown) => + selector === selectActivePlaylist + ? activePlaylist + : harnessSelectSignal(selector)) as typeof harnessSelectSignal); + 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({ sessionKey: 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({ + sessionKey: component.playbackSessionKey(), + streamUrls: ['http://localhost/second.m3u8'], + }); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( + 1 + ); + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith( + 'playlist-1', + expect.objectContaining({ title: 'Second TV' }) + ); + }); + + 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); + + expect( + playlistsServiceMock.addM3uRecentlyViewed + ).not.toHaveBeenCalled(); + + // The same URL opened elsewhere (MPV/VLC, another playlist) does not. + gate.confirm({ streamUrls: [radio.url] }); + expect( + playlistsServiceMock.addM3uRecentlyViewed + ).not.toHaveBeenCalled(); + + gate.confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: [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.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index aa4710be4..1580b5338 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -106,10 +106,13 @@ } @else { 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 c2e29d313..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 @@ -121,6 +121,7 @@ import { SettingsStore, TmdbEnrichmentService, } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { Channel, createDevLogger, @@ -238,6 +239,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); @@ -487,7 +489,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(); @@ -530,7 +542,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 @@ -838,13 +850,17 @@ 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; } this.lastRecordedRecentKey = nextKey; - void this.persistRecentlyViewedChannel(playlistId, activeChannel); + untracked(() => + this.recordRecentlyViewedChannel(playlistId, activeChannel) + ); }); effect(() => { @@ -1200,6 +1216,32 @@ 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( + { + sessionKey: this.playbackSessionKey(), + streamUrls: [channel.url], + }, + record + ); + } + private async persistRecentlyViewedChannel( playlistId: string, channel: Channel diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts index 607e35535..f405f92d5 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts @@ -97,6 +97,7 @@ export class StubAudioPlayerComponent { readonly channelName = input(''); readonly channelLogo = input(''); readonly volume = input(null); + readonly playbackSessionKey = input(null); readonly volumeChange = output(); } 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..f3fce3fd1 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,8 +8,10 @@ import { PORTAL_PLAYER, UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { ElectronStreamHeadersService } from '@iptvnator/ui/playback'; import { UnifiedLiveTimeshift } from './unified-live-catchup'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; export interface UnifiedLiveSelection { @@ -71,6 +73,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 +122,27 @@ export function createUnifiedLiveSelection(options: { }); }; + /** Set by `dispose`: the host is gone and must not be notified. */ + let disposed = false; + + /** + * Runs once the row has really played. That is seconds after selection, + * so the user may have moved on — the write still happened, and an open + * Recently Viewed list must still move the row to the top. + */ + const recordLivePlayback = async ( + item: UnifiedCollectionItem + ): Promise => { + try { + const updatedItem = await recentData.recordLivePlayback(item); + if (!disposed) { + options.onItemPlayed(updatedItem); + } + } catch { + // Keep playback/EPG visible even if history persistence fails. + } + }; + const close = (): void => { options.generation.next(); pendingActivation = null; @@ -223,14 +247,25 @@ 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. Played + // inline, the tab's playlist-scoped session key — the one its + // players confirm with — survives a switch to catch-up and does + // not match the same URL in another playlist. MPV/VLC can only + // confirm the launched URL, so an external row defers by URL. + const playsInline = !options.shouldOpenExternalPlayback( + detail, + true + ); + historyGate.defer( + { + sessionKey: playsInline + ? createUnifiedLivePlaybackSessionKey(item) + : null, + streamUrls: [detail.playback.streamUrl], + }, + () => void recordLivePlayback(item) + ); if ( generation === options.generation.current() && @@ -264,6 +299,7 @@ export function createUnifiedLiveSelection(options: { activate, close, dispose(): void { + disposed = true; // Invalidate a playback continuation still awaiting its header // IPC and drop any radio credentials owned by this tab. options.generation.next(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 24ec1f36a..54a2d27f1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -58,6 +58,7 @@ activeDetail()?.playback?.title || '' " + [playbackSessionKey]="playbackSessionKey()" /> } @else { { window.electron = originalElectron; }); + /** What the mounted player reports once the stream has really played. */ + async function playStream(streamUrl: string): Promise { + TestBed.inject(PlaybackHistoryGate).confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: [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, @@ -659,6 +671,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(); @@ -712,6 +726,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( @@ -782,6 +798,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( @@ -841,6 +859,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) @@ -874,6 +894,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) @@ -897,6 +919,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/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts index 9c32c3d7a..4e8d81360 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts @@ -10,6 +10,7 @@ import { import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg'; import { ResizableDirective } from '@iptvnator/ui/components'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces'; import { PORTAL_PLAYER, @@ -21,6 +22,7 @@ import { } from '@iptvnator/portal/shared/data-access'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; import { StubAudioPlayerComponent, StubEpgTimelineComponent, @@ -173,6 +175,105 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => { } ); + it('correlates an inline row with its session key, not its stream URL', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + const item = buildM3uLiveItem(); + streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + await component.onChannelSelected(component.channelsForList()[0]); + const gate = TestBed.inject(PlaybackHistoryGate); + + // The same URL played from another playlist does not confirm it. + gate.confirm({ + sessionKey: 'live:another-playlist:m3u-channel', + streamUrls: [item.streamUrl], + }); + await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + + // The row's own player does, even after switching to catch-up. + gate.confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: ['https://example.com/archive.m3u8'], + }); + await fixture.whenStable(); + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); + }); + + it('records a row played in MPV/VLC from the URL its launch confirms', async () => { + const item = buildM3uLiveItem(); + streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + await component.onChannelSelected(component.channelsForList()[0]); + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalled(); + + // The external session update carries only the launched URL. + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: [item.streamUrl], + }); + await fixture.whenStable(); + + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); + }); + + it('moves a row that played to the top of Recently Viewed after another row was selected', async () => { + const first = buildM3uLiveItem(); + const second: UnifiedCollectionItem = { + ...first, + uid: 'm3u::pl-1::m3u-channel-2', + name: 'M3U Live 2', + channelId: 'm3u-channel-2', + tvgId: 'm3u-channel-2', + streamUrl: 'https://example.com/m3u-2.m3u8', + }; + streamResolver.resolveM3uPlaybackDetail.mockImplementation( + async (item: UnifiedCollectionItem) => ({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }) + ); + recentData.recordLivePlayback.mockImplementation( + async (item: UnifiedCollectionItem) => item + ); + const played = jest.fn(); + component.itemPlayed.subscribe(played); + fixture.componentRef.setInput('items', [first, second]); + fixture.componentRef.setInput('mode', 'recent'); + fixture.detectChanges(); + await fixture.whenStable(); + const row = (name: string) => + component + .channelsForList() + .find((channel) => channel.name === name)!; + + await component.onChannelSelected(row('M3U Live')); + await component.onChannelSelected(row('M3U Live 2')); + // The first row's playback is confirmed only now, after the switch. + TestBed.inject(PlaybackHistoryGate).confirm({ + sessionKey: createUnifiedLivePlaybackSessionKey(first), + streamUrls: [first.streamUrl], + }); + await fixture.whenStable(); + + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(first); + expect(played).toHaveBeenCalledWith(first); + }); + it('keeps the current detail (and its fullscreen player) mounted while the next selection resolves', async () => { const first = buildM3uLiveItem(); const second: UnifiedCollectionItem = { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts index b383ad633..005fc0ef4 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -101,6 +101,7 @@ export class StubAudioPlayerComponent { readonly icon = input(''); readonly url = input.required(); readonly channelName = input(''); + readonly playbackSessionKey = input(null); } @Component({ 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..adec67f2d 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 @@ -5,6 +5,7 @@ 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 { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { of } from 'rxjs'; import { PlaylistMeta, @@ -13,6 +14,13 @@ 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({ + streamUrls: [playback.streamUrl], + }); +} + jest.mock('@iptvnator/portal/shared/util', () => ({ ...jest.requireActual('@iptvnator/portal/shared/util'), createLogger: () => ({ @@ -160,6 +168,7 @@ describe('withStalkerPlayer', () => { 'Movie Title', 'thumb.jpg' ); + confirmPlayback(playback); expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith( 1, @@ -225,6 +234,7 @@ describe('withStalkerPlayer', () => { 1, 3000001 ); + confirmPlayback(playback); expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith( PLAYLIST._id, @@ -266,6 +276,7 @@ describe('withStalkerPlayer', () => { logo: 'jazz.png', category_id: '4001', }); + confirmPlayback(playback); expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith( @@ -372,6 +383,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 +567,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..6cff03cca 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 @@ -5,6 +5,7 @@ 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 { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { PlaylistMeta, ResolvedPortalPlayback, @@ -60,7 +61,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 +124,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 +149,12 @@ export function withStalkerPlayer() { cover, title ); - - if (typeof storeState.addToRecentlyViewed === 'function') { - storeState.addToRecentlyViewed(recentItem); - return; - } - - persistRecentlyViewed(playlistId, recentItem); + historyGate.defer({ streamUrls: [streamUrl] }, () => + persistRecentlyViewed(playlistId, { + ...recentItem, + added_at: Date.now(), + }) + ); }; const resolveVodPlaybackInternal = async ( @@ -157,9 +166,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 +202,13 @@ export function withStalkerPlayer() { } ); - recordRecentlyViewed(item, cmd, thumbnail, title); + recordRecentlyViewed( + streamUrl, + item, + cmd, + thumbnail, + title + ); const isEpisode = episodeNum !== undefined || episodeId !== undefined; @@ -285,6 +298,7 @@ export function withStalkerPlayer() { const portalOrigin = getStalkerPortalOrigin(playlist); recordRecentlyViewed( + streamUrl, item, item.cmd, item.logo ?? item.cover, @@ -350,6 +364,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 2fc7c4932..5d8bd41f8 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..120846c99 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 @@ -26,6 +26,7 @@ import { import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; import { BehaviorSubject, EMPTY, of } from 'rxjs'; @@ -564,12 +565,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({ + streamUrls: ['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 +932,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 +970,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 +1077,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..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 @@ -5,8 +5,15 @@ import { PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, } from '@iptvnator/portal/shared/util'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { + PlaybackPositionRuntimeBridgeService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import type { PlaybackPositionData, PlayerContentInfo, @@ -28,6 +35,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => { /** The bridge callback the service registers at construction. */ let positionListener: ((data: PlaybackPositionData) => void) | undefined; const addRecentItem = jest.fn(); + const xtreamDataSource = { + getContentByXtreamId: jest.fn(), + addRecentItem: jest.fn(), + }; const activeSession = signal(null); const closeSession = jest.fn().mockResolvedValue(undefined); const openResolvedPlayback = jest.fn(); @@ -81,6 +92,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => { routeVodId.set(ROUTE_VOD_ID); positionListener = undefined; addRecentItem.mockClear(); + xtreamDataSource.getContentByXtreamId + .mockReset() + .mockResolvedValue({ id: 77 }); + xtreamDataSource.addRecentItem.mockReset().mockResolvedValue(undefined); closeSession.mockReset().mockResolvedValue(undefined); openResolvedPlayback .mockReset() @@ -105,6 +120,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { .mockReturnValue('https://example.com/route.mkv'), }, }, + { provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource }, { provide: PORTAL_EXTERNAL_PLAYBACK, useValue: { activeSession, closeSession }, @@ -147,6 +163,95 @@ 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', + }); + expect(addRecentItem).not.toHaveBeenCalled(); + + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['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('saves a confirmation that arrives after a playlist switch without touching the store', async () => { + // Only a slow MPV/VLC launch can confirm after the page is gone; the + // store's recent list belongs to the other playlist by then. + await service.startResolvedPlayback({ + 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(); + + expect(addRecentItem).not.toHaveBeenCalled(); + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + ROUTE_VOD_ID, + ROUTE_PLAYLIST, + 'movie' + ); + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + 77, + ROUTE_PLAYLIST, + undefined + ); + }); + + 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)); @@ -341,6 +446,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => { contentType: 'vod', }, }); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['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..222935734 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -0,0 +1,85 @@ +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, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; + +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. + * + * A confirmation that arrives after the user switched to another playlist + * (only a slow MPV/VLC launch can: the inline player goes with the page) is + * saved to the captured playlist without touching the store, whose recent + * list belongs to the other playlist by then. + * + * Must run in an injection context. + */ +export function injectXtreamRecentHistory(): ( + streamUrl: string, + request: XtreamRecentItemRequest +) => void { + const gate = inject(PlaybackHistoryGate); + const store = inject(XtreamStore); + const injector = inject(Injector); + + return (streamUrl, request) => { + const playlist = signal(store.currentPlaylist()).asReadonly(); + gate.defer({ streamUrls: [streamUrl] }, () => { + const playlistId = playlist()?.id; + if (store.currentPlaylist()?.id === playlistId) { + store.addRecentItem({ ...request, playlist }); + } 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 + ); + } + }); + }; +} + +/** + * 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 { + const id = Number(xtreamId); + if (!Number.isFinite(id) || id <= 0) { + return; + } + const content = await dataSource.getContentByXtreamId( + id, + playlistId, + contentType + ); + const contentId = content?.id ?? (keysByXtreamId ? id : null); + if (contentId != null) { + await dataSource.addRecentItem(contentId, playlistId, backdropUrl); + } +} diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts index dcee640f3..a9b998aa4 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts @@ -29,7 +29,9 @@ describe('ArtPlayerVideoSession', () => { player.currentTime = 45; player.duration = 120; player.emit('video:timeupdate'); - expect(times).toEqual([{ currentTime: 45, duration: 120 }]); + expect(times).toEqual([ + { currentTime: 45, duration: 120, playing: false }, + ]); Object.defineProperty(player.video, 'error', { configurable: true, @@ -70,7 +72,7 @@ describe('ArtPlayerVideoSession', () => { it('uses the source-resolved duration for time updates', () => { const player = createPlayer(); - const times: Array<{ currentTime: number; duration: number }> = []; + const times: unknown[] = []; player.currentTime = 45; player.duration = Number.POSITIVE_INFINITY; const session = new ArtPlayerVideoSession({ @@ -87,7 +89,9 @@ describe('ArtPlayerVideoSession', () => { player.emit('video:timeupdate'); - expect(times).toEqual([{ currentTime: 45, duration: 135 }]); + expect(times).toEqual([ + { currentTime: 45, duration: 135, playing: false }, + ]); }); it('removes native and ArtPlayer listeners exactly on destroy', () => { diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts index 86570eb31..b3d2a852b 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts @@ -5,6 +5,7 @@ import { classifyNativePlaybackIssue, createPlaybackSourceMetadata, } from '@iptvnator/playback/util'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; export interface ArtPlayerVideoSessionConfig { player: Artplayer; @@ -13,7 +14,7 @@ export interface ArtPlayerVideoSessionConfig { getDuration: () => number; persistSharedVolume: boolean; emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; - emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; + emitTimeUpdate: (value: PlayerTimeUpdate) => void; emitPlaybackEnded: () => void; emitPlaybackStarted?: () => void; } @@ -71,6 +72,7 @@ export class ArtPlayerVideoSession { this.config.emitTimeUpdate({ currentTime: player.currentTime, duration: this.config.getDuration(), + playing: !player.video.paused && !player.video.seeking, }); }; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 986ff3923..bc815ac91 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -35,6 +35,7 @@ import { } from './art-player-setup'; import { ArtPlayerSourceSession } from './art-player-source-session'; import { ArtPlayerVideoSession } from './art-player-video-session'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugArtPlayer = createDevLogger('ArtPlayer'); @@ -64,10 +65,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly playbackStarted = output(); 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..268dea187 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/playback/data-access'; import { AudioPlayerComponent } from './audio-player.component'; describe('AudioPlayerComponent', () => { @@ -176,6 +177,47 @@ 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( + { streamUrls: ['https://example.com/station.mp3'] }, + commit + ); + Object.defineProperty(audio, 'paused', { + configurable: true, + value: false, + }); + 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( + { streamUrls: ['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..34469d51a 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/playback/data-access'; +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'], @@ -176,6 +183,8 @@ export class AudioPlayerComponent { readonly channelName = input(''); readonly externalVolume = input(null, { alias: 'volume' }); readonly dispatchAdjacentChannelAction = input(true); + /** The host's playback session key, for the history confirmation. */ + readonly playbackSessionKey = input(null); readonly channelSwitchRequested = output<'next' | 'previous'>(); readonly volumeChange = output(); @@ -198,6 +207,14 @@ 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), + target: () => ({ + sessionKey: this.playbackSessionKey(), + streamUrls: [this.url()], + }), + }); constructor() { const saved = parseFloat(localStorage.getItem('volume') ?? '1'); @@ -268,6 +285,16 @@ export class AudioPlayerComponent { } } + onTimeUpdate(): void { + const audio = this.audioRef()?.nativeElement; + if (audio) { + this.historyConfirmation.record( + audio.currentTime, + !audio.paused && !audio.seeking + ); + } + } + play() { const audio = this.audioRef()?.nativeElement; if (!audio) return; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index c768ba534..b35a715c6 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -56,6 +56,7 @@ import { subtitleTrackLabel, volumeIcon, } from './embedded-mpv-format.utils'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000; @@ -89,10 +90,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); @@ -567,6 +565,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.timeUpdate.emit({ currentTime: session.positionSeconds, duration: session.durationSeconds ?? 0, + playing: session.status === 'playing', }); this.legacyInteractions.scheduleControlsHide(); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts index d1a44277b..cc0352fcb 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts @@ -4,8 +4,11 @@ describe('HtmlVideoElementSession', () => { it('owns native video events and detaches them idempotently', () => { const video = document.createElement('video'); const playbackIssues: unknown[] = []; - const timeUpdates: Array<{ currentTime: number; duration: number }> = - []; + const timeUpdates: Array<{ + currentTime: number; + duration: number; + playing?: boolean; + }> = []; const playbackEnded = jest.fn(); const removeEventListener = jest.spyOn(video, 'removeEventListener'); const session = new HtmlVideoElementSession({ @@ -40,7 +43,20 @@ describe('HtmlVideoElementSession', () => { video.dispatchEvent(new Event('ended')); expect(video.currentTime).toBe(18); - expect(timeUpdates).toEqual([{ currentTime: 18, duration: 90 }]); + // A paused element's position moves too (a seek): not playing. + expect(timeUpdates).toEqual([ + { currentTime: 18, duration: 90, playing: false }, + ]); + Object.defineProperty(video, 'paused', { + configurable: true, + value: false, + }); + video.dispatchEvent(new Event('timeupdate')); + expect(timeUpdates.at(-1)).toEqual({ + currentTime: 18, + duration: 90, + playing: true, + }); expect(playbackIssues[0]).toEqual( expect.objectContaining({ code: 'unsupported-container', diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts index 190fce46c..a443da253 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts @@ -5,6 +5,7 @@ import { classifyNativePlaybackIssue, createPlaybackSourceMetadata, } from '@iptvnator/playback/util'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); @@ -13,7 +14,7 @@ export interface HtmlVideoElementSessionConfig { getChannelUrl: () => string | undefined; getStartTime: () => number; emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; - emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; + emitTimeUpdate: (value: PlayerTimeUpdate) => void; emitPlaybackEnded: () => void; emitPlaybackStarted?: () => void; } @@ -62,6 +63,7 @@ export class HtmlVideoElementSession { this.config.emitTimeUpdate({ currentTime: video.currentTime, duration: video.duration, + playing: !video.paused && !video.seeking, }); }; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 8de5124a6..b4a5ce6a5 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -54,6 +54,7 @@ import { HtmlVideoPlayerControlsBridge, type HtmlVideoControlsSource, } from './html-video-player-controls.bridge'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); @@ -85,10 +86,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - @Output() timeUpdate = new EventEmitter<{ - currentTime: number; - duration: number; - }>(); + @Output() timeUpdate = new EventEmitter(); @Output() playbackIssue = new EventEmitter(); @Output() playbackEnded = new EventEmitter(); @Output() playbackStarted = new EventEmitter(); 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..be7916a2a --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts @@ -0,0 +1,75 @@ +import type { PlaybackHistoryTarget } from '@iptvnator/playback/data-access'; +import { PlaybackHistoryConfirmation } from './playback-history-confirmation'; + +describe('PlaybackHistoryConfirmation', () => { + const streamA: PlaybackHistoryTarget = { + sessionKey: 'live:p1:a', + streamUrls: ['http://stream/a'], + }; + const streamB: PlaybackHistoryTarget = { + sessionKey: 'live:p1:b', + streamUrls: ['http://stream/b'], + }; + let target: PlaybackHistoryTarget; + let revision: symbol; + let confirm: jest.Mock; + let confirmation: PlaybackHistoryConfirmation; + + const play = (...positions: number[]) => + positions.forEach((position) => confirmation.record(position)); + + beforeEach(() => { + target = streamA; + revision = Symbol('first'); + confirm = jest.fn(); + confirmation = new PlaybackHistoryConfirmation({ + gate: { confirm }, + target: () => target, + sourceRevision: () => revision, + }); + }); + + it('confirms what played', () => { + play(0, 1, 2); + + expect(confirm).toHaveBeenCalledWith(streamA); + }); + + it('does not credit a new stream with the previous stream progress', () => { + play(0, 1.5); + target = streamB; + play(0, 0.5); + + expect(confirm).not.toHaveBeenCalled(); + + play(1, 2); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledWith(streamB); + }); + + it('confirms a returning stream again after another one played', () => { + play(0, 1, 2); + target = streamB; + play(0, 1, 2); + target = streamA; + 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); + }); + + it('ignores position changes reported while not playing', () => { + confirmation.record(0, false); + confirmation.record(1, false); + confirmation.record(2, false); + + expect(confirm).not.toHaveBeenCalled(); + }); +}); 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..ec8413ea0 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts @@ -0,0 +1,49 @@ +import type { + PlaybackHistoryGate, + PlaybackHistoryTarget, +} from '@iptvnator/playback/data-access'; +import { PlaybackProgressConfirmation } from './playback-progress-confirmation'; + +export interface PlaybackHistoryConfirmationOptions { + readonly gate: Pick; + /** What is playing now: its session key and/or stream URLs. */ + readonly target: () => PlaybackHistoryTarget; + /** + * 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.target()) + ); + + constructor(private readonly options: PlaybackHistoryConfirmationOptions) {} + + /** @param playing see {@link PlaybackProgressConfirmation.record}. */ + record(position: number, playing?: boolean): void { + const identity = JSON.stringify(this.options.target()); + 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, playing); + } +} 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..991b0fc6d --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts @@ -0,0 +1,109 @@ +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 short seeks of paused media', () => { + confirmation.record(0, false); + confirmation.record(1, false); + confirmation.record(2, false); + confirmation.record(2.5, false); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('counts only the steps reported while playing', () => { + confirmation.record(0, true); + confirmation.record(1.5, true); + // Paused, then seeked a second ahead: not watched. + confirmation.record(2.5, false); + confirmation.record(2.9, true); + + expect(onConfirmed).not.toHaveBeenCalled(); + + confirmation.record(3.5, true); + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + 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..a6397013f --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts @@ -0,0 +1,66 @@ +/** 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 — including + * short seeks of paused media, which engines report as `playing: false`. + */ +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; + } + + /** + * @param playing whether the media was actually playing (not paused, not + * seeking) at this report; engines that cannot tell leave it undefined. + */ + record(position: number, playing?: boolean): void { + if (this.confirmed || !Number.isFinite(position)) { + return; + } + + const previous = this.lastPosition; + this.lastPosition = position; + if (previous === null || playing === false) { + 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/playback-history/player-time-update.ts b/libs/ui/playback/src/lib/playback-history/player-time-update.ts new file mode 100644 index 000000000..10b4a4000 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/player-time-update.ts @@ -0,0 +1,12 @@ +/** Position report an engine emits on every media time update. */ +export interface PlayerTimeUpdate { + readonly currentTime: number; + readonly duration: number; + /** + * Whether the media was actually playing — not paused and not seeking. + * Seeks of paused media also move the position; only playing reports + * count towards the "really played" history confirmation. Left undefined + * by an engine that cannot tell. + */ + readonly playing?: boolean; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index ab92365dd..33cc796b8 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -51,6 +51,7 @@ import { getVideoJsTechVideo, } from './vjs-player.types'; import { VjsVideoElementSession } from './vjs-video-element-session'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugVjsPlayer = createDevLogger('VjsPlayer'); @@ -82,10 +83,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly playbackStarted = output(); @@ -282,6 +280,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.timeUpdate.emit({ currentTime: this.player.currentTime() ?? 0, duration: this.player.duration() ?? 0, + playing: !this.player.paused() && !this.player.seeking(), }); }; diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts index ab005aa84..1593036e2 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts @@ -83,6 +83,38 @@ describe('ExternalPlaybackRecoveryCoordinator', () => { coordinator.destroy(); }); + it.each([ + ['an opened launch', session({ status: 'opened' }), 1], + ['a launch that failed', session({ status: 'error' }), 0], + ['a launch of another player', session({ player: 'vlc' }), 0], + ])( + 'reports %s to the view', + async (_label, launched: ExternalPlayerSession, calls: number) => { + const activeSession = signal(null); + const onLaunched = jest.fn(); + const coordinator = new ExternalPlaybackRecoveryCoordinator( + { + activeSession, + visibleSession: activeSession, + closeSession: jest.fn(), + dismissActiveSession: jest.fn(), + }, + onLaunched + ); + coordinator.syncSession('content-a'); + + coordinator.request('mpv', jest.fn(), (trackLaunch) => { + trackLaunch(Promise.resolve(launched)); + return true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(onLaunched).toHaveBeenCalledTimes(calls); + coordinator.destroy(); + } + ); + it('closes a closable error before retrying another external target', async () => { const previous = session({ id: 'uncertain-process', diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts index ad9ab6f14..53f1a7eee 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts @@ -21,8 +21,16 @@ export class ExternalPlaybackRecoveryCoordinator { readonly states = this.recovery.states; readonly pending = this.recovery.pending; + /** + * @param onLaunched runs once a launch this view requested has opened in + * MPV/VLC — still owned by the current intent — e.g. to record it as + * recently viewed under the view's own playback session. + */ constructor( - private readonly externalPlayback: PortalExternalPlayback | null + private readonly externalPlayback: PortalExternalPlayback | null, + private readonly onLaunched: ( + session: ExternalPlayerSession + ) => void = () => undefined ) {} observe(session: ExternalPlayerSession | null): void { @@ -93,7 +101,13 @@ export class ExternalPlaybackRecoveryCoordinator { void launch.then( (session) => { if (session) { - this.recovery.confirm(intent, session); + if ( + this.recovery.confirm(intent, session) && + (session.status === 'opened' || + session.status === 'playing') + ) { + this.onLaunched(session); + } } else { this.recovery.fail(intent); } 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..2639def1a --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts @@ -0,0 +1,199 @@ +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 { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +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({ sessionKey: '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({ streamUrls: ['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({ sessionKey: '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 count seeks of paused media as watched time', () => { + const commit = jest.fn(); + gate.defer({ sessionKey: 'live:p1:a' }, commit); + + [0, 1, 2, 3].forEach((currentTime) => + vjs().timeUpdate.emit({ currentTime, duration: 0, playing: false }) + ); + + 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({ sessionKey: '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 b567e3f35..b5947f51e 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 @@ -23,6 +23,7 @@ import { } from '@iptvnator/playback/util'; import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { VideoPlayer, type Channel, @@ -36,6 +37,8 @@ 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 { PlayerTimeUpdate } from '../playback-history/player-time-update'; import { type PlayerMediaTitle, PlayerUpNextItem, @@ -117,8 +120,16 @@ export class WebPlayerViewComponent implements OnDestroy { optional: true, }); private readonly recoverySession = new PlaybackRecoverySession(); + private readonly historyGate = inject(PlaybackHistoryGate); private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator( - this.externalPlayback + this.externalPlayback, + // "Open in MPV/VLC" after an inline failure is still this session's + // view; the app-wide session confirmation carries only the URL. + (session) => + this.historyGate.confirm({ + sessionKey: this.playbackSessionKey(), + streamUrls: [session.streamUrl], + }) ); private readonly applicationHandoff = new WebPlayerApplicationHandoffCoordinator( @@ -216,6 +227,15 @@ 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: this.historyGate, + target: () => ({ + sessionKey: this.playbackSessionKey(), + streamUrls: [this.streamUrl(), this.playback()?.streamUrl], + }), + sourceRevision: () => this.playbackSourceRevisionToken(), + }); readonly playbackExternallyTransferable = computed(() => isPlaybackExternallyTransferable(this.resolvedPlayback()) ); @@ -358,7 +378,7 @@ export class WebPlayerViewComponent implements OnDestroy { } handleTimeUpdate( - event: { currentTime: number; duration: number }, + event: PlayerTimeUpdate, ownership: PlaybackApplicationOwnership ): void { if ( @@ -380,6 +400,7 @@ export class WebPlayerViewComponent implements OnDestroy { } this.recoverySession.recordTimeUpdate(event, ownership.isLive); + this.historyConfirmation.record(event.currentTime, event.playing); this.timeUpdate.emit(event); } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index 1b67d68d8..400585d11 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -5,6 +5,7 @@ import type { RecordingStartMetadata, RecordingStoppedEvent, } from '@iptvnator/shared/interfaces'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; /** * Player stand-ins for WebPlayerViewComponent specs. They mirror the real @@ -26,7 +27,7 @@ export class StubVjsPlayerComponent { readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -49,7 +50,7 @@ export class StubHtmlVideoPlayerComponent { readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -72,7 +73,7 @@ export class StubArtPlayerComponent { readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -102,7 +103,7 @@ export class StubEmbeddedMpvPlayerComponent { readonly recordingFolder = input(''); readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index 379cad26f..ef1d82de8 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -89,6 +89,13 @@ "validationCommand": "pnpm nx test epg-data-access", "e2eTags": ["@epg"] }, + { + "name": "playback-data-access", + "root": "libs/playback/data-access", + "sourceRoot": "libs/playback/data-access/src", + "validationCommand": "pnpm nx test playback-data-access", + "e2eTags": ["@persistence"] + }, { "name": "m3u-state", "root": "libs/m3u-state", diff --git a/tsconfig.base.json b/tsconfig.base.json index e708d951e..b5eb9accb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -39,6 +39,9 @@ "libs/portal/shared/util/src/tokens.ts" ], "@iptvnator/epg/data-access": ["libs/epg/data-access/src/index.ts"], + "@iptvnator/playback/data-access": [ + "libs/playback/data-access/src/index.ts" + ], "@iptvnator/playback/util": ["libs/playback/util/src/index.ts"], "@iptvnator/playlist/shared/ui": [ "libs/playlist/shared/ui/src/index.ts"