Skip to content

Commit d0e1b20

Browse files
committed
fix: mpris can now represent unsafe values due to sanitization, no longer crashes.
1 parent 5adf3ff commit d0e1b20

File tree

4 files changed

+88
-35
lines changed

4 files changed

+88
-35
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"cSpell.words": [
2020
"Brainz",
2121
"Castlabs",
22+
"Dbus",
2223
"Fi's",
2324
"flac",
2425
"Flatpak",

src/TidalControllers/ReduxController/ReduxController.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export class ReduxController implements TidalController<ReduxControllerOptions>
123123
const artists = this.useSelector(
124124
(state) => state.content.mediaItems[this.getTrackId()].item.artists,
125125
);
126-
return artists.map((artist) => artist.name);
126+
return artists ? artists.map((artist) => artist.name) : [];
127127
}
128128

129129
getArtistsString() {
@@ -170,7 +170,10 @@ export class ReduxController implements TidalController<ReduxControllerOptions>
170170
}
171171

172172
getDuration() {
173-
return this.useSelector((state) => state.playbackControls.playbackContext.actualDuration);
173+
const duration = this.useSelector(
174+
(state) => state.playbackControls.playbackContext.actualDuration,
175+
);
176+
return typeof duration === "number" && Number.isFinite(duration) ? duration : 0;
174177
}
175178

176179
getCurrentlyPlayingStatus() {
@@ -203,7 +206,7 @@ export class ReduxController implements TidalController<ReduxControllerOptions>
203206
}
204207

205208
getTrackId() {
206-
return this.useSelector((state) => state.playbackControls.mediaProduct.productId);
209+
return this.useSelector((state) => state.playbackControls.mediaProduct.productId) || "";
207210
}
208211

209212
isFavorite() {

src/features/mpris/mprisService.ts

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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-Za-z0-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
}

src/features/tidal/url.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ export const getTrackURL = (trackId: string) => {
1212
};
1313

1414
/**
15-
* Build a cover url given the id
15+
* Build a cover url given the id.
16+
* If the input is already a full URL (e.g. for uploaded content), return it as-is.
1617
*/
1718
export const getCoverURL = (coverId: string, size: 1280 | 80 = 1280) => {
19+
if (!coverId) return "";
20+
if (coverId.startsWith("http://") || coverId.startsWith("https://")) return coverId;
1821
return `https://resources.tidal.com/images/${coverId.replace(/-/g, "/")}/${size}x${size}.jpg`;
1922
};
2023

0 commit comments

Comments
 (0)