From 621f7c38fbad79120805b114c4f40cc63dc3396f Mon Sep 17 00:00:00 2001 From: skalthoff <32023561+skalthoff@users.noreply.github.com> Date: Tue, 16 Dec 2025 02:54:40 -0800 Subject: [PATCH] feat: Add URL validation before file downloads and ensure valid session IDs for tracks and streams. (#813) --- .../mutations/download/offlineModeUtils.ts | 18 ++++++++++--- src/utils/mappings.ts | 25 ++++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/api/mutations/download/offlineModeUtils.ts b/src/api/mutations/download/offlineModeUtils.ts index 7238d192..eec57bca 100644 --- a/src/api/mutations/download/offlineModeUtils.ts +++ b/src/api/mutations/download/offlineModeUtils.ts @@ -60,6 +60,11 @@ export async function downloadJellyfinFile( setDownloadProgress: JellifyDownloadProgressState, preferredExtension?: string | null, ): Promise { + // Validate URL before attempting download to prevent NPE in native code + if (!url || url.trim() === '') { + throw new Error('Invalid download URL: URL is empty or undefined') + } + try { const urlExtension = normalizeExtension(getExtensionFromUrl(url)) const hintedExtension = normalizeExtension(preferredExtension) @@ -168,6 +173,12 @@ export const saveAudio = async ( //Ignore } + // Validate track URL before attempting download + if (!track.url || track.url.trim() === '') { + console.error('Cannot download track: URL is missing', track.item.Id) + return false + } + try { const downloadedTrackFile = await downloadJellyfinFile( track.url, @@ -177,10 +188,11 @@ export const saveAudio = async ( track.mediaSourceInfo?.Container, ) let downloadedArtworkFile: DownloadedFileInfo | undefined - if (track.artwork) { + // Check for non-empty artwork URL (empty string passes truthy check but fails download) + if (track.artwork && typeof track.artwork === 'string' && track.artwork.trim() !== '') { downloadedArtworkFile = await downloadJellyfinFile( - track.artwork as string, - track.item.Id as string, + track.artwork, + `${track.item.Id}-artwork`, track.title as string, setDownloadProgress, undefined, diff --git a/src/utils/mappings.ts b/src/utils/mappings.ts index 39d1879c..2d4817e0 100644 --- a/src/utils/mappings.ts +++ b/src/utils/mappings.ts @@ -23,6 +23,18 @@ import StreamingQuality from '../enums/audio-quality' import { getAudioCache } from '../api/mutations/download/offlineModeUtils' import RNFS from 'react-native-fs' +/** + * Ensures a valid session ID is returned. + * The ?? operator doesn't catch empty strings, so we need this helper. + * Empty session IDs cause MusicService to crash with "Session ID must be unique. ID=" + */ +function getValidSessionId(sessionId: string | null | undefined): string { + if (sessionId && sessionId.trim() !== '') { + return sessionId + } + return uuid.v4().toString() +} + /** * Gets the artwork URL for a track, prioritizing the track's own artwork over the album's artwork. * Falls back to artist image if no album artwork is available. @@ -169,16 +181,21 @@ function ensureFileUri(path?: string): string | undefined { } function buildDownloadedTrack(downloadedTrack: JellifyDownload): TrackMediaInfo { + // Safely build the image path - artwork is optional and may be undefined + const imagePath = downloadedTrack.artwork + ? `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.artwork.split('/').pop()}` + : undefined + return { type: TrackType.Default, url: `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.path!.split('/').pop()}`, - image: `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.artwork!.split('/').pop()}`, + image: imagePath, duration: convertRunTimeTicksToSeconds( downloadedTrack.mediaSourceInfo?.RunTimeTicks || downloadedTrack.item.RunTimeTicks || 0, ), item: downloadedTrack.item, mediaSourceInfo: downloadedTrack.mediaSourceInfo, - sessionId: downloadedTrack.sessionId, + sessionId: getValidSessionId(downloadedTrack.sessionId), sourceType: 'download', } } @@ -198,7 +215,7 @@ function buildTranscodedTrack( duration: convertRunTimeTicksToSeconds(RunTimeTicks ?? 0), mediaSourceInfo, item, - sessionId, + sessionId: getValidSessionId(sessionId), sourceType: 'stream', } } @@ -228,7 +245,7 @@ function buildAudioApiUrl( const mediaSource = mediaInfo!.MediaSources![0] urlParams = { - playSessionId: mediaInfo?.PlaySessionId ?? uuid.v4(), + playSessionId: getValidSessionId(mediaInfo?.PlaySessionId), startTimeTicks: '0', static: 'true', }