From 8431c0ab584839fe2cc2dc67300d964d958bd61b Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 3 Sep 2026 14:50:49 -0700 Subject: [PATCH] fix(mobile): don't offer share-to-story for a track with no audio Share-to-story builds a video out of the track's audio: it resolves the stream URL and hands it to ffmpeg as an input. When the track has nothing to play - an upload indexed without its track_cid - that URL 404s, ffmpeg exits non-zero, and the user gets a bare "Sorry, something went wrong." with no hint that the track itself is broken. That is the bug Michael hit; every other link he tried worked because those tracks had audio. The API now reports is_streamable=false for these, so stop offering the story platforms at all rather than failing halfway through. The check is an explicit `!== false` because not every track source populates the field, and an absent one must not hide the share options. Also guard the stream-URL step itself. Nothing wrapped it, so a rejection there - a failed signature, an SDK that never initialized - escaped as an unhandled promise rejection: no toast at all, and the progress drawer left spinning with no way out but backing out of it. Co-Authored-By: Claude Opus 5 --- packages/common/src/models/Track.ts | 6 +++-- .../common/src/utils/trackAvailability.ts | 8 +++--- .../components/share-drawer/ShareDrawer.tsx | 7 +++++ .../share-drawer/useShareToStory.tsx | 27 ++++++++++++------- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts index 77d3f789727..5c307e00599 100644 --- a/packages/common/src/models/Track.ts +++ b/packages/common/src/models/Track.ts @@ -195,8 +195,10 @@ export type TrackMetadata = { is_available: boolean /** * Whether the API will serve audio for this track. The API sets it to false - * when the track is deleted or its owner is no longer active (a self - * deactivation or a trusted-notifier delist). Optional because not every + * when the track is deleted, when its owner is no longer active (a self + * deactivation or a trusted-notifier delist), or when the row carries no + * track_cid - an upload that transcoded fine but was indexed without the cid + * pointing at the audio, leaving nothing to play. Optional because not every * track source populates it, so treat `undefined` as "no opinion" rather * than as "not streamable". */ diff --git a/packages/common/src/utils/trackAvailability.ts b/packages/common/src/utils/trackAvailability.ts index 2c31a9e0aa1..c8ab69f9aff 100644 --- a/packages/common/src/utils/trackAvailability.ts +++ b/packages/common/src/utils/trackAvailability.ts @@ -7,10 +7,12 @@ type MaybeTrack = Pick & * Whether a track should be shown as no longer available. * * The API reports this via `is_streamable`, which it sets to false when the - * track is deleted or its owner is no longer active - either because the + * track is deleted, when its owner is no longer active - either because the * artist deactivated their own account or because the account was delisted by - * the trusted notifier. Deleted tracks are excluded here because they have - * their own, more specific "deleted by artist" treatment. + * the trusted notifier - or when the row has no track_cid, an upload that was + * indexed without the cid pointing at its audio. Deleted tracks are excluded + * here because they have their own, more specific "deleted by artist" + * treatment. * * The check is an explicit `=== false` on purpose: not every track source * populates `is_streamable`, and an absent field must not be read as diff --git a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx index c02430ccd03..6d3f25426e0 100644 --- a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx +++ b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx @@ -160,11 +160,18 @@ export const ShareDrawer = NiceModal.create(() => { } }, [dispatch, content, source]) + // The story platforms build a video out of the track's audio, so a track with + // nothing to play cannot be shared to them. `is_streamable` is false for an + // upload that was indexed without its track_cid: the stream URL 404s, ffmpeg + // fails on it, and the user gets a bare "something went wrong" with no idea + // why. Compare against `false` explicitly - an absent field means the source + // did not populate it, not that the track is broken. const isShareableTrack = content?.type === 'track' && !content.track.is_unlisted && !content.track.is_invalid && !content.track.is_delete && + content.track.is_streamable !== false && !isStreamGatedTrack const performActionAndClose = useCallback( diff --git a/packages/mobile/src/components/share-drawer/useShareToStory.tsx b/packages/mobile/src/components/share-drawer/useShareToStory.tsx index b068d652578..45cb68f0d96 100644 --- a/packages/mobile/src/components/share-drawer/useShareToStory.tsx +++ b/packages/mobile/src/components/share-drawer/useShareToStory.tsx @@ -366,16 +366,25 @@ export const useShareToStory = ({ // For simplicity, assume that calculating dominant colors and generating the sticker takes 20% of the total loading time: dispatch(setProgress(20)) - const { data, signature } = - await audiusBackendInstance.signGatedContentRequest({ - sdk + // Nothing here was guarded before, so a rejection - a failed signature, + // an SDK that never initialized - escaped as an unhandled promise + // rejection: no toast, and the progress drawer left spinning forever. + let streamMp3Url: string + try { + const { data, signature } = + await audiusBackendInstance.signGatedContentRequest({ + sdk + }) + streamMp3Url = await sdk.tracks.getTrackStreamUrl({ + trackId: Id.parse(content.track.track_id), + userId: OptionalId.parse(userId), + userSignature: signature, + userData: data }) - const streamMp3Url = await sdk.tracks.getTrackStreamUrl({ - trackId: Id.parse(content.track.track_id), - userId: OptionalId.parse(userId), - userSignature: signature, - userData: data - }) + } catch (e) { + handleError(platform, e, 'Error at resolve stream url step') + return + } const storyVideoPath = path.join( RNFS.TemporaryDirectoryPath, `storyVideo-${uuid()}.mp4`