From b6c71258e421b3ecaf13f776c438860d7880ccde Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 17 Apr 2026 23:19:33 +0200 Subject: [PATCH] fix(player): playback position persistence for vlc Entire-Checkpoint: c6e522b4276c --- .../src/app/events/player.events.spec.ts | 44 ++++ .../src/app/events/player.events.ts | 198 ++++++++++++++---- .../stalker-catalog-facade.service.spec.ts | 131 ++++++++++++ .../src/lib/stalker-catalog-facade.service.ts | 5 + 4 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index 80dbdbb94..016b0c59b 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -28,6 +28,8 @@ jest.mock('../services/stalker-playback-context.service', () => ({ import { buildExternalPlayerSpawnSpec, isRunningInFlatpak, + parseVlcRcPlaybackState, + parseVlcRcNumericResponse, resolveExternalPlayerLaunchContext, shouldReuseMpvInstance, shouldUseMpvSocketBridge, @@ -125,4 +127,46 @@ describe('player.events Flatpak launch helpers', () => { expect(shouldReuseMpvInstance(false, false)).toBe(false); expect(shouldUseMpvSocketBridge(false)).toBe(true); }); + + it('parses numeric VLC RC responses that include the prompt prefix', () => { + expect( + parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari +Command Line Interface initialized. Type \`help' for help. +> 20 +> `) + ).toBe('20'); + + expect( + parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari +Command Line Interface initialized. Type \`help' for help. +> 9281 +> `) + ).toBe('9281'); + }); + + it('returns an empty string for non-numeric VLC RC responses', () => { + expect( + parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari +Command Line Interface initialized. Type \`help' for help. +> ( state playing ) +> `) + ).toBe(''); + }); + + it('parses VLC RC playback states from status output', () => { + expect( + parseVlcRcPlaybackState(`VLC media player 3.0.20 Vetinari +Command Line Interface initialized. Type \`help' for help. +> ( audio volume: 196 ) +( state playing ) +> `) + ).toBe('playing'); + + expect( + parseVlcRcPlaybackState(`VLC media player 3.0.20 Vetinari +Command Line Interface initialized. Type \`help' for help. +> ( state stopped ) +> `) + ).toBe('stopped'); + }); }); diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 6719758f4..f8de5c802 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -31,6 +31,11 @@ let mpvProcess: ChildProcess | null = null; let mpvSocketPath: string | null = null; let positionPollingInterval: NodeJS.Timeout | null = null; +interface ExternalPlaybackSnapshot { + positionSeconds: number; + durationSeconds: number | null; +} + function sendExternalPlayerSessionUpdate(session: ExternalPlayerSession) { if (App.mainWindow && !App.mainWindow.isDestroyed()) { App.mainWindow.webContents.send(EXTERNAL_PLAYER_SESSION_UPDATE, session); @@ -212,6 +217,24 @@ function sendPlayerErrorNotification(player: 'MPV' | 'VLC', error: string) { } } +function sendPlaybackPositionUpdate( + sessionId: string, + contentInfo: any, + snapshot: ExternalPlaybackSnapshot +) { + if (!App.mainWindow || App.mainWindow.isDestroyed()) { + return; + } + + externalPlayerSessions.markPlaying(sessionId); + App.mainWindow.webContents.send('playback-position-update', { + sessionId, + positionSeconds: snapshot.positionSeconds, + durationSeconds: snapshot.durationSeconds, + ...contentInfo, + }); +} + // Query MPV property via IPC async function getMpvProperty( socketPath: string, @@ -277,6 +300,16 @@ async function getMpvProperty( }); } +export function parseVlcRcNumericResponse(data: string): string { + const match = data.match(/>\s*(-?\d+(?:\.\d+)?)/); + return match ? match[1] : ''; +} + +export function parseVlcRcPlaybackState(data: string): string | null { + const match = data.match(/\(\s*state\s+([^)]+)\s*\)/i); + return match ? match[1].trim().toLowerCase() : null; +} + function stopPositionPolling() { if (positionPollingInterval) { clearInterval(positionPollingInterval); @@ -351,25 +384,10 @@ function startPositionPolling( // console.log(`[PlayerEvents] Polling MPV: pos=${position}, dur=${duration}`); if (position !== null && App.mainWindow) { - externalPlayerSessions.markPlaying(sessionId); - /* - console.log('[PlayerEvents] Sending playback-position-update', { + sendPlaybackPositionUpdate(sessionId, contentInfo, { positionSeconds: Math.floor(position), durationSeconds: duration ? Math.floor(duration) : null, - ...contentInfo, }); - */ - App.mainWindow.webContents.send( - 'playback-position-update', - { - sessionId, - positionSeconds: Math.floor(position), - durationSeconds: duration - ? Math.floor(duration) - : null, - ...contentInfo, - } - ); } } catch (err) { // console.error('[PlayerEvents] Error during polling:', err); @@ -390,7 +408,10 @@ function stopVlcPositionPolling() { } } -async function getVlcProperty(port: number, command: string): Promise { +async function getVlcCommandResponse( + port: number, + command: string +): Promise { return new Promise((resolve) => { const client = createConnection({ port, host: '127.0.0.1' }); let data = ''; @@ -414,9 +435,7 @@ async function getVlcProperty(port: number, command: string): Promise { data += chunk.toString(); // VLC prompt '>' means command finished if (data.includes('>')) { - // Parse: find numeric value (for get_time/get_length) - const match = data.match(/^(\d+(\.\d+)?)\s*$/m); - done(match ? match[1] : ''); + done(data); } }); @@ -424,10 +443,43 @@ async function getVlcProperty(port: number, command: string): Promise { }); } +async function getVlcProperty(port: number, command: string): Promise { + return parseVlcRcNumericResponse( + await getVlcCommandResponse(port, command) + ); +} + +async function getVlcPlaybackState(port: number): Promise { + return parseVlcRcPlaybackState( + await getVlcCommandResponse(port, 'status') + ); +} + +async function getVlcPlaybackSnapshot( + port: number +): Promise { + const timeStr = await getVlcProperty(port, 'get_time'); + const lenStr = await getVlcProperty(port, 'get_length'); + + const position = parseInt(timeStr, 10); + const duration = parseInt(lenStr, 10); + + if (isNaN(position)) { + return null; + } + + return { + positionSeconds: position, + durationSeconds: !isNaN(duration) ? duration : null, + }; +} + function startVlcPositionPolling( port: number, contentInfo: any, - sessionId: string + sessionId: string, + onSnapshot?: (snapshot: ExternalPlaybackSnapshot) => void, + onStopped?: () => void ) { stopVlcPositionPolling(); @@ -441,30 +493,29 @@ function startVlcPositionPolling( */ vlcPollingInterval = setInterval(async () => { try { - const timeStr = await getVlcProperty(port, 'get_time'); - const lenStr = await getVlcProperty(port, 'get_length'); + const snapshot = await getVlcPlaybackSnapshot(port); - const position = parseInt(timeStr, 10); - const duration = parseInt(lenStr, 10); - - if (!isNaN(position) && App.mainWindow) { - externalPlayerSessions.markPlaying(sessionId); - App.mainWindow.webContents.send( - 'playback-position-update', - { - sessionId, - positionSeconds: position, - durationSeconds: !isNaN(duration) ? duration : null, - ...contentInfo, - } + if (snapshot && App.mainWindow) { + onSnapshot?.(snapshot); + sendPlaybackPositionUpdate( + sessionId, + contentInfo, + snapshot ); + return; + } + + const playbackState = await getVlcPlaybackState(port); + if (playbackState === 'stopped') { + onStopped?.(); + stopVlcPositionPolling(); } } catch (err) { // console.error('[PlayerEvents] VLC polling error:', err); stopVlcPositionPolling(); } - }, 5000); - }, 3000); + }, 2000); + }, 1500); } // Helper function to send command to MPV via IPC @@ -1003,6 +1054,7 @@ ipcMain.handle( } console.log('VLC Args:', args); + let lastVlcSnapshot: ExternalPlaybackSnapshot | null = null; // Wrap spawn in a promise to catch startup errors await new Promise((resolve, reject) => { @@ -1017,16 +1069,66 @@ ipcMain.handle( stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown }); + const markVlcSessionClosed = () => { + if ( + externalPlayerSessions.getSession(session.id)?.status === + 'closed' + ) { + return; + } + + if (lastVlcSnapshot && contentInfo) { + sendPlaybackPositionUpdate( + session.id, + contentInfo, + lastVlcSnapshot + ); + } + + externalPlayerSessions.markClosed(session.id); + }; + + const flushVlcPlaybackPosition = async () => { + if (isRetry || rcPort <= 0 || !contentInfo) { + return; + } + + const snapshot = + (await getVlcPlaybackSnapshot(rcPort)) ?? + lastVlcSnapshot; + if (!snapshot) { + return; + } + + lastVlcSnapshot = snapshot; + sendPlaybackPositionUpdate( + session.id, + contentInfo, + snapshot + ); + }; + externalPlayerSessions.attachCloser(session.id, async () => { + await flushVlcPlaybackPosition(); if (!proc.killed) { proc.kill(); } }); // Start polling if we have port and content info and NOT retrying (RC disabled on retry) - if (!isRetry && rcPort > 0 && contentInfo) { - startVlcPositionPolling(rcPort, contentInfo, session.id); - } + if (!isRetry && rcPort > 0 && contentInfo) { + startVlcPositionPolling( + rcPort, + contentInfo, + session.id, + (snapshot) => { + lastVlcSnapshot = snapshot; + }, + () => { + markVlcSessionClosed(); + } + ); + } // Capture stdout if (proc.stdout) { @@ -1077,6 +1179,20 @@ ipcMain.handle( proc.on('exit', (code) => { console.log(`VLC exited with code ${code}`); + stopVlcPositionPolling(); + + if ( + lastVlcSnapshot && + contentInfo && + externalPlayerSessions.getSession(session.id) + ?.status !== 'closed' + ) { + sendPlaybackPositionUpdate( + session.id, + contentInfo, + lastVlcSnapshot + ); + } if (code === 1 && !isRetry && rcPort > 0) { console.log( diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts new file mode 100644 index 000000000..3b7c76c9b --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -0,0 +1,131 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util'; +import { PlaybackPositionData } from 'shared-interfaces'; +import { StalkerCatalogFacadeService } from './stalker-catalog-facade.service'; + +describe('StalkerCatalogFacadeService', () => { + const playlist = { + _id: 'playlist-1', + title: 'Demo Stalker', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', + userAgent: 'DemoAgent', + referrer: 'http://demo.example', + origin: 'http://demo.example', + }; + const unsubscribe = jest.fn(); + let playbackUpdateHandler: + | ((data: PlaybackPositionData) => void) + | undefined; + let playbackPositions: { + savePlaybackPosition: jest.Mock, [string, PlaybackPositionData]>; + getPlaybackPosition: jest.Mock, [string, number, 'vod' | 'episode']>; + getSeriesPlaybackPositions: jest.Mock, [string, number]>; + getRecentPlaybackPositions?: jest.Mock; + getAllPlaybackPositions: jest.Mock, [string]>; + clearPlaybackPosition: jest.Mock, [string, number, 'vod' | 'episode']>; + }; + + beforeEach(() => { + playbackUpdateHandler = undefined; + unsubscribe.mockReset(); + playbackPositions = { + savePlaybackPosition: jest.fn().mockResolvedValue(undefined), + getPlaybackPosition: jest.fn().mockResolvedValue(null), + getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]), + getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), + }; + + (window as Window & { electron?: typeof window.electron }).electron = { + ...(window.electron ?? {}), + onPlaybackPositionUpdate: jest.fn( + (handler: (data: PlaybackPositionData) => void) => { + playbackUpdateHandler = handler; + return unsubscribe; + } + ), + }; + + TestBed.configureTestingModule({ + providers: [ + StalkerCatalogFacadeService, + { + provide: StalkerStore, + useValue: { + selectedContentType: signal<'vod' | 'series' | 'itv'>('vod'), + limit: signal(14), + page: signal(0), + getSelectedCategory: signal(null), + getPaginatedContent: signal([]), + selectedItem: signal(null), + getTotalPages: signal(0), + isPaginatedContentLoading: signal(false), + currentPlaylist: signal(playlist), + getSelectedCategoryName: jest.fn(() => null), + setSelectedCategory: jest.fn(), + clearSelectedItem: jest.fn(), + setPage: jest.fn(), + setLimit: jest.fn(), + setSelectedItem: jest.fn(), + createLinkToPlayVod: jest.fn(), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + fetchMovieFileId: jest.fn(), + fetchLinkToPlay: jest.fn(), + resolveVodPlayback: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: playbackPositions, + }, + ], + }); + }); + + it('persists matching external playback updates for the current playlist', async () => { + TestBed.inject(StalkerCatalogFacadeService); + await Promise.resolve(); + + expect(playbackUpdateHandler).toBeDefined(); + + playbackUpdateHandler?.({ + playlistId: playlist._id, + contentXtreamId: 17359, + contentType: 'vod', + positionSeconds: 42, + durationSeconds: 5400, + }); + await Promise.resolve(); + + expect(playbackPositions.savePlaybackPosition).toHaveBeenCalledWith( + playlist._id, + expect.objectContaining({ + playlistId: playlist._id, + contentXtreamId: 17359, + contentType: 'vod', + positionSeconds: 42, + durationSeconds: 5400, + }) + ); + }); + + it('ignores external playback updates for other playlists', async () => { + TestBed.inject(StalkerCatalogFacadeService); + await Promise.resolve(); + + playbackUpdateHandler?.({ + playlistId: 'playlist-2', + contentXtreamId: 17359, + contentType: 'vod', + positionSeconds: 42, + durationSeconds: 5400, + }); + await Promise.resolve(); + + expect(playbackPositions.savePlaybackPosition).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 15fdc798d..2d509a3f8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -129,6 +129,11 @@ export class StalkerCatalogFacadeService return; } + void this.playbackPositions.savePlaybackPosition( + data.playlistId, + data + ); + if (data.contentType === 'vod') { this.updateVodPlaybackPosition(data); }