fix(player): playback position persistence for vlc

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-17 23:19:33 +02:00
1 parent 8be51fc246
commit b6c71258e4
4 files changed
+337 -41

No files matched your search

@@ -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');
});
});
@@ -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<string> {
async function getVlcCommandResponse(
port: number,
command: string
): Promise<string> {
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<string> {
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<string> {
});
}
async function getVlcProperty(port: number, command: string): Promise<string> {
return parseVlcRcNumericResponse(
await getVlcCommandResponse(port, command)
);
}
async function getVlcPlaybackState(port: number): Promise<string | null> {
return parseVlcRcPlaybackState(
await getVlcCommandResponse(port, 'status')
);
}
async function getVlcPlaybackSnapshot(
port: number
): Promise<ExternalPlaybackSnapshot | null> {
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<void>((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(
@@ -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<Promise<void>, [string, PlaybackPositionData]>;
getPlaybackPosition: jest.Mock<Promise<PlaybackPositionData | null>, [string, number, 'vod' | 'episode']>;
getSeriesPlaybackPositions: jest.Mock<Promise<PlaybackPositionData[]>, [string, number]>;
getRecentPlaybackPositions?: jest.Mock;
getAllPlaybackPositions: jest.Mock<Promise<PlaybackPositionData[]>, [string]>;
clearPlaybackPosition: jest.Mock<Promise<void>, [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();
});
});
@@ -129,6 +129,11 @@ export class StalkerCatalogFacadeService
return;
}
void this.playbackPositions.savePlaybackPosition(
data.playlistId,
data
);
if (data.contentType === 'vod') {
this.updateVodPlaybackPosition(data);
}