From e6ad644ee82d6f794aa2550697e22a8ed17e6b86 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 25 Dec 2025 23:33:21 +0100 Subject: [PATCH] chore: improve player error handling App imports and streams capture MPV stdout/stderr now friendly errors sent --- .../src/app/api/main.preload.ts | 14 +- .../src/app/events/player.events.ts | 174 +++++++++++++++++- apps/web/src/app/services/electron.service.ts | 49 ++++- global.d.ts | 22 ++- 4 files changed, 244 insertions(+), 15 deletions(-) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index e9291cd7d..dc1434a28 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -2,9 +2,21 @@ import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('electron', { // Remote control channel change listener - onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => { + onChannelChange: ( + callback: (data: { direction: 'up' | 'down' }) => void + ) => { ipcRenderer.on('CHANNEL_CHANGE', (_event, data) => callback(data)); }, + // Player error listener + onPlayerError: ( + callback: (data: { + player: string; + error: string; + originalError: string; + }) => void + ) => { + ipcRenderer.on('player-error', (_event, data) => callback(data)); + }, getAppVersion: () => ipcRenderer.invoke('get-app-version'), platform: process.platform, fetchPlaylistByUrl: (url: string, title?: string) => diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 05b97e016..4331c7617 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -5,6 +5,7 @@ import { ipcMain } from 'electron'; SET_MPV_PLAYER_PATH, SET_VLC_PLAYER_PATH, } from 'shared-interfaces'; */ +import App from '../app'; import { MPV_PLAYER_PATH, MPV_REUSE_INSTANCE, @@ -27,6 +28,45 @@ export default class PlayerEvents { let mpvProcess: ChildProcess | null = null; let mpvSocketPath: string | null = null; +// Helper function to send error notifications to the renderer +function sendPlayerErrorNotification(player: 'MPV' | 'VLC', error: string) { + if (App.mainWindow && !App.mainWindow.isDestroyed()) { + // Make error message more user-friendly + let userMessage = error; + + if (error.includes('Failed to open')) { + userMessage = + 'Failed to open stream. The URL may be invalid or the server is not responding.'; + } else if ( + error.includes('Protocol not found') || + error.includes('Unsupported protocol') + ) { + userMessage = + 'Unsupported stream protocol. Please check the stream URL.'; + } else if ( + error.includes('Connection refused') || + error.includes('Could not connect') + ) { + userMessage = + 'Cannot connect to the stream server. Please check your internet connection.'; + } else if (error.includes('403') || error.includes('Forbidden')) { + userMessage = + 'Access denied. The stream may require valid credentials or headers.'; + } else if (error.includes('404') || error.includes('Not Found')) { + userMessage = + 'Stream not found. The URL may be incorrect or expired.'; + } else if (error.includes('Timed out') || error.includes('timeout')) { + userMessage = 'Connection timed out. The server is not responding.'; + } + + App.mainWindow.webContents.send('player-error', { + player, + error: userMessage, + originalError: error, + }); + } +} + // Helper function to send command to MPV via IPC function sendMpvCommand(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { @@ -140,9 +180,64 @@ ipcMain.handle( const proc = spawn(mpvPath, args, { shell: false, detached: !reuseInstance, - stdio: 'ignore', + // Only pipe stdio when reusing instance; use 'ignore' for detached to allow clean shutdown + stdio: reuseInstance ? ['ignore', 'pipe', 'pipe'] : 'ignore', }); + // Capture stdout + if (proc.stdout) { + proc.stdout.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + console.log('[MPV stdout]:', output); + + // MPV sometimes outputs errors to stdout instead of stderr + if ( + output.includes('Failed to open') || + output.includes('Error opening') || + output.includes('Protocol not found') || + output.includes('Connection refused') || + output.includes('error') || + output.includes('403') || + output.includes('404') + ) { + console.error( + '[MPV ERROR from stdout]:', + output + ); + sendPlayerErrorNotification('MPV', output); + } + } + }); + } + + // Capture stderr (MPV outputs most messages here) + if (proc.stderr) { + proc.stderr.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + console.error('[MPV stderr]:', output); + + // Check for common error patterns and send notifications + if ( + output.includes('Failed to open') || + output.includes( + 'Exiting... (Errors when loading file)' + ) || + output.includes('Error opening') || + output.includes('Protocol not found') || + output.includes('Connection refused') || + output.includes('error') || + output.includes('403') || + output.includes('404') + ) { + console.error('[MPV ERROR]:', output); + sendPlayerErrorNotification('MPV', output); + } + } + }); + } + proc.on('error', (err) => { console.error('Failed to start MPV player:', err); mpvProcess = null; @@ -158,6 +253,17 @@ ipcMain.handle( console.log(`MPV exited with code ${code}`); mpvProcess = null; mpvSocketPath = null; + + // Log non-zero exit codes as errors and notify user + if (code !== 0 && code !== null) { + console.error( + `[MPV ERROR] MPV exited with error code ${code}` + ); + sendPlayerErrorNotification( + 'MPV', + `MPV player closed unexpectedly (exit code: ${code})` + ); + } }); // Store the process reference if reuse is enabled @@ -254,9 +360,62 @@ ipcMain.handle( const proc = spawn(vlcPath, args, { shell: false, detached: true, - stdio: 'ignore', + stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown }); + // Capture stdout + if (proc.stdout) { + proc.stdout.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + console.log('[VLC stdout]:', output); + + // VLC sometimes outputs errors to stdout instead of stderr + if ( + output.includes('error') || + output.includes('Error') || + output.includes('failed') || + output.includes('Failed') || + output.includes('cannot open') || + output.includes('Connection refused') || + output.includes('403') || + output.includes('404') + ) { + console.error( + '[VLC ERROR from stdout]:', + output + ); + sendPlayerErrorNotification('VLC', output); + } + } + }); + } + + // Capture stderr + if (proc.stderr) { + proc.stderr.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + console.error('[VLC stderr]:', output); + + // Check for common error patterns and send notifications + if ( + output.includes('error') || + output.includes('Error') || + output.includes('failed') || + output.includes('Failed') || + output.includes('cannot open') || + output.includes('Connection refused') || + output.includes('403') || + output.includes('404') + ) { + console.error('[VLC ERROR]:', output); + sendPlayerErrorNotification('VLC', output); + } + } + }); + } + proc.on('error', (err) => { console.error('Failed to start VLC player:', err); reject( @@ -268,6 +427,17 @@ ipcMain.handle( proc.on('exit', (code) => { console.log(`VLC exited with code ${code}`); + + // Log non-zero exit codes as errors and notify user + if (code !== 0 && code !== null) { + console.error( + `[VLC ERROR] VLC exited with error code ${code}` + ); + sendPlayerErrorNotification( + 'VLC', + `VLC player closed unexpectedly (exit code: ${code})` + ); + } }); // Detach the process so it can continue running independently diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 3bee601e9..730bba602 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -27,6 +27,30 @@ export class ElectronService extends DataService { constructor() { super(); console.log('Electron service initialized...'); + this.setupPlayerErrorListener(); + } + + private setupPlayerErrorListener() { + // Listen for player errors from the backend + if (window.electron?.onPlayerError) { + window.electron.onPlayerError( + (data: { + player: string; + error: string; + originalError: string; + }) => { + console.error(`${data.player} Error:`, data.originalError); + this.snackBar.open( + `${data.player} Error: ${data.error}`, + 'Close', + { + duration: 7000, + panelClass: ['error-snackbar'], + } + ); + } + ); + } } getAppVersion(): string { @@ -70,9 +94,13 @@ export class ElectronService extends DataService { /* thumbnail: data.thumbnail ?? '', */ } catch (error: any) { const errorMessage = error?.message || String(error); - this.snackBar.open(`Error launching MPV: ${errorMessage}`, 'Close', { - duration: 5000, - }); + this.snackBar.open( + `Error launching MPV: ${errorMessage}`, + 'Close', + { + duration: 5000, + } + ); console.error('MPV launch error:', error); throw error; } @@ -88,9 +116,13 @@ export class ElectronService extends DataService { ); } catch (error: any) { const errorMessage = error?.message || String(error); - this.snackBar.open(`Error launching VLC: ${errorMessage}`, 'Close', { - duration: 5000, - }); + this.snackBar.open( + `Error launching VLC: ${errorMessage}`, + 'Close', + { + duration: 5000, + } + ); console.error('VLC launch error:', error); throw error; } @@ -230,7 +262,10 @@ export class ElectronService extends DataService { // Log error to console if (isStatusCheck) { - console.log('Portal status check failed - portal may be unavailable:', error.message || error); + console.log( + 'Portal status check failed - portal may be unavailable:', + error.message || error + ); } else { console.error('Xtream request error:', error.message); } diff --git a/global.d.ts b/global.d.ts index e5acaa335..f84587d6d 100644 --- a/global.d.ts +++ b/global.d.ts @@ -79,17 +79,21 @@ declare global { dbDeletePlaylist: ( playlistId: string ) => Promise<{ success: boolean }>; - dbDeleteXtreamContent: ( - playlistId: string - ) => Promise<{ + dbDeleteXtreamContent: (playlistId: string) => Promise<{ success: boolean; favoritedXtreamIds: number[]; - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; + recentlyViewedXtreamIds: { + xtreamId: number; + viewedAt: string; + }[]; }>; dbRestoreXtreamUserData: ( playlistId: string, favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] + recentlyViewedXtreamIds: { + xtreamId: number; + viewedAt: string; + }[] ) => Promise<{ success: boolean }>; dbHasCategories: ( playlistId: string, @@ -160,6 +164,14 @@ declare global { onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void ) => void; + // Player error notifications + onPlayerError?: ( + callback: (data: { + player: string; + error: string; + originalError: string; + }) => void + ) => void; getLocalIpAddresses: () => Promise; }; process: NodeJS.Process;