@@ -18,6 +18,7 @@ export class MprisService {
1818 private player : Player | null = null ;
1919 private currentPosition = 0 ; // Track current position in seconds
2020 private isReconnecting = false ;
21+ private static readonly TIDAL_RESOURCE_PREFIX = "https://resources.tidal.com/images/" ;
2122
2223 constructor ( private mainWindow : BrowserWindow ) { }
2324
@@ -29,6 +30,53 @@ export class MprisService {
2930 this . createMprisPlayer ( ) ;
3031 }
3132
33+ /**
34+ * Sanitize a trackId into a valid D-Bus object path segment.
35+ * D-Bus object paths only allow [A-Za-z0-9_].
36+ * Uploaded music and videos may have UUIDs, URLs, or other non-numeric IDs.
37+ */
38+ private sanitizeTrackIdForDbus ( trackId : string | undefined ) : string {
39+ if ( ! trackId ) return "0" ;
40+ const sanitized = trackId . replace ( / [ ^ A - Z a - z 0 - 9 _ ] / g, "_" ) ;
41+ return sanitized || "0" ;
42+ }
43+
44+ /**
45+ * Ensure a value is a finite number, returning the fallback otherwise.
46+ * Prevents NaN/undefined/Infinity from reaching D-Bus serialization.
47+ */
48+ private safeNumber ( value : unknown , fallback : number ) : number {
49+ return typeof value === "number" && Number . isFinite ( value ) ? value : fallback ;
50+ }
51+
52+ /**
53+ * Coerce an object's values into valid D-Bus metadata types (strings and finite numbers).
54+ * Non-finite numbers, undefined, and null are dropped; everything else is stringified.
55+ */
56+ private filterForDbusMetadata ( obj : Record < string , unknown > ) : Record < string , string | number > {
57+ const filtered : Record < string , string | number > = { } ;
58+ for ( const [ key , value ] of Object . entries ( obj ) ) {
59+ if ( value === null || value === undefined ) continue ;
60+ if ( typeof value === "number" && ! Number . isFinite ( value ) ) continue ;
61+
62+ filtered [ key ] =
63+ typeof value === "string" || ( typeof value === "number" && Number . isFinite ( value ) )
64+ ? value
65+ : JSON . stringify ( value ) ;
66+ }
67+ return filtered ;
68+ }
69+
70+ /**
71+ * Check whether an error indicates a broken D-Bus stream that requires reconnection.
72+ */
73+ private isStreamError ( error : unknown ) : boolean {
74+ return (
75+ error instanceof Error &&
76+ ( error . message . includes ( "EPIPE" ) || error . message . includes ( "broken" ) )
77+ ) ;
78+ }
79+
3280 private createMprisPlayer ( ) : void {
3381 try {
3482 if ( this . player ) {
@@ -68,7 +116,7 @@ export class MprisService {
68116 // Handle D-Bus errors and EPIPE errors
69117 this . player . on ( "error" , ( error : Error ) => {
70118 Logger . log ( "MPRIS error occurred:" , error ) ;
71- if ( error . message . includes ( "EPIPE" ) || error . message . includes ( "broken pipe" ) ) {
119+ if ( this . isStreamError ( error ) ) {
72120 Logger . log ( "MPRIS stream broken, attempting to reconnect..." ) ;
73121 this . handleStreamError ( ) ;
74122 }
@@ -152,7 +200,7 @@ export class MprisService {
152200 if ( ! this . player ) return ;
153201
154202 this . player . on ( "quit" , ( ) => {
155- this . mainWindow . webContents . send ( "globalEvent" , globalEvents . quit ) ;
203+ this . sendToRenderer ( globalEvents . quit ) ;
156204 } ) ;
157205 }
158206
@@ -169,8 +217,21 @@ export class MprisService {
169217
170218 try {
171219 // Update current position if available
172- if ( mediaInfo . currentInSeconds > 0 ) {
173- this . currentPosition = mediaInfo . currentInSeconds ;
220+ this . currentPosition = this . safeNumber ( mediaInfo . currentInSeconds , 0 ) ;
221+
222+ // Sanitize values before sending to D-Bus
223+ const safeTrackId = this . sanitizeTrackIdForDbus ( mediaInfo . trackId ) ;
224+ const safeDuration = this . safeNumber ( mediaInfo . durationInSeconds , 0 ) ;
225+ const safeVolume = Math . max ( 0 , Math . min ( 1 , this . safeNumber ( mediaInfo . volume , 1.0 ) ) ) ;
226+ const customMetadata = this . filterForDbusMetadata ( ObjectToDotNotation ( mediaInfo , "custom:" ) ) ;
227+
228+ // Guard against double-prefixed image URLs (Tidal bug with uploaded content)
229+ let artUrl = mediaInfo . image || "" ;
230+ if ( artUrl . startsWith ( MprisService . TIDAL_RESOURCE_PREFIX ) ) {
231+ const afterPrefix = artUrl . substring ( MprisService . TIDAL_RESOURCE_PREFIX . length ) ;
232+ if ( afterPrefix . startsWith ( "http://" ) || afterPrefix . startsWith ( "https://" ) ) {
233+ artUrl = afterPrefix ;
234+ }
174235 }
175236
176237 // Safely update metadata
@@ -181,11 +242,11 @@ export class MprisService {
181242 "xesam:artist" : [ mediaInfo . artists || "" ] ,
182243 "xesam:album" : mediaInfo . album || "" ,
183244 "xesam:url" : mediaInfo . url || "" ,
184- "mpris:artUrl" : mediaInfo . image || "" ,
185- "mpris:length" : convertSecondsToMicroseconds ( mediaInfo . durationInSeconds ) ,
186- "mpris:trackid" : `/org/mpris/MediaPlayer2/track/${ mediaInfo . trackId } ` ,
245+ "mpris:artUrl" : artUrl ,
246+ "mpris:length" : convertSecondsToMicroseconds ( safeDuration ) ,
247+ "mpris:trackid" : `/org/mpris/MediaPlayer2/track/${ safeTrackId } ` ,
187248 } ,
188- ...ObjectToDotNotation ( mediaInfo , "custom:" ) ,
249+ ...customMetadata ,
189250 } ;
190251
191252 this . player . playbackStatus = mediaInfo . status === MediaStatus . paused ? "Paused" : "Playing" ;
@@ -201,25 +262,21 @@ export class MprisService {
201262 this . player . loopStatus = mprisLoopStatus || "None" ;
202263 }
203264
204- this . player . volume = Math . max ( 0 , Math . min ( 1 , mediaInfo . volume || 1.0 ) ) ;
265+ this . player . volume = safeVolume ;
205266 } else {
206267 // Use reasonable defaults if player state is not available
207268 this . player . shuffle = false ;
208269 this . player . loopStatus = "None" ;
209270 }
210271 } catch ( error ) {
211272 Logger . log ( "Error updating MPRIS metadata:" , error ) ;
212- // If error is related to broken stream, handle it
213- if (
214- error instanceof Error &&
215- ( error . message . includes ( "EPIPE" ) || error . message . includes ( "broken" ) )
216- ) {
273+ if ( this . isStreamError ( error ) ) {
217274 this . handleStreamError ( ) ;
218275 }
219276 }
220277 }
221278
222- private async handleMprisEvent ( eventName : string , eventData : unknown ) : Promise < void > {
279+ private handleMprisEvent ( eventName : string , eventData : unknown ) : void {
223280 if ( ! this . player || this . isReconnecting ) {
224281 return ; // Skip events during reconnection
225282 }
@@ -262,11 +319,7 @@ export class MprisService {
262319 }
263320 } catch ( error ) {
264321 Logger . log ( `Error handling MPRIS event ${ eventName } :` , error ) ;
265- // If error is related to broken stream, handle it
266- if (
267- error instanceof Error &&
268- ( error . message . includes ( "EPIPE" ) || error . message . includes ( "broken" ) )
269- ) {
322+ if ( this . isStreamError ( error ) ) {
270323 this . handleStreamError ( ) ;
271324 }
272325 }
@@ -327,20 +380,13 @@ export class MprisService {
327380 }
328381
329382 destroy ( ) : void {
330- if ( this . isReconnecting ) {
331- this . isReconnecting = false ;
332- }
383+ this . isReconnecting = false ;
333384
334385 if ( this . player ) {
335- try {
336- // Try to gracefully clean up the MPRIS player
337- this . player = null ;
338- this . currentPosition = 0 ;
339- Logger . log ( "MPRIS player destroyed successfully" ) ;
340- } catch ( error ) {
341- Logger . log ( "Error destroying MPRIS player" , error ) ;
342- }
386+ this . player = null ;
387+ this . currentPosition = 0 ;
343388 }
389+
344390 Logger . log ( "MPRIS service destroyed" ) ;
345391 }
346392}
0 commit comments