Skip to content

Commit 3e40dec

Browse files
dylanjeffersclaude
andauthored
[Mobile] Fix offline album track downloads after v1 SDK migration (#14275)
## Summary Track downloads were failing in offline mode — the user-visible symptom is *"clicks download, sees a track attempt, then a failure icon."* This affects **both individual track downloads** (favoriting a track with auto-download on, or any other path that queues a single track) **and tracks inside an album/collection download**, because every track-job entry point lands in the same queue and is processed by the same worker. Two compounding issues in `downloadTrackWorker.ts`, both effectively introduced by the v1 SDK migration (#13728): 1. **`downloadTrackAudio` skipped the v1 pre-signed stream URL.** It built the URL via `sdk.tracks.getTrackStreamUrl()`, ignoring `track.stream.url`. The v1 API returns a pre-signed stream URL (with mirrors) on the track itself; `AudioPlayer` prefers it for online streaming and only falls back to manual URL construction when `stream.url` is missing. The offline worker skipped that working path entirely, so it relied on the fallback for every track. 2. **`downloadTrackCoverArt` was fatal.** It threw when no artwork URI returned 200, and because cover art runs in `all([cover, audio, json])`, a single flaky artwork host failed the entire track download — even though audio is the only essential payload. The collection cover art download follows the opposite (best-effort) pattern; this PR brings the track version in line. Plus `downloadTrackAsync`'s `catch` block silently swallowed the error — no logs, no Sentry breadcrumb. That's why this had been hard to diagnose. ### Why this fixes both individual tracks and collections Every track-job source — `requestDownloadCollectionSaga`, `requestDownloadFavoritedCollectionSaga`, `requestDownloadAllFavoritesSaga`, `watchSaveTrackSaga` (standalone track favorite), `watchAddTrackToPlaylistSaga`, `syncCollectionWorker` — dispatches `addOfflineEntries({ items: [..., { type: 'track' }, ...] })`. The queue processor routes every `type === 'track'` job to a single `downloadTrackWorker`, which calls the two functions patched here. So both flows share the same broken code path. ## Changes - `downloadTrackAudio`: prefer `track.stream.url` (with mirror substitution), keep `getTrackStreamUrl()` + manual signing as a last-resort fallback. Mirrors `AudioPlayer.tsx` logic. - `downloadTrackCoverArt`: best-effort — return silently if all URIs fail rather than throwing. - Extract shared `buildMirrorUris(primary, mirrors)` helper used by both audio and cover art. - Add a `console.warn` in the catch block of `downloadTrackAsync` so future failures are diagnosable instead of silent. ## Test plan - [ ] Offline mode → favorite a single track with "download all favorites" enabled. Track reaches SUCCESS, plays from local file. - [ ] Offline mode → download an album you don't own (where you've favorited it). All tracks reach SUCCESS, audio plays from local file. - [ ] Offline mode → download an album where the artwork host is degraded. Audio still downloads successfully; cover art is best-effort. - [ ] Offline mode → download a single track via the track menu. Still works (no regression). - [ ] Offline mode → toggle "Download all favorites." Track downloads succeed. - [ ] Offline mode → play a downloaded track while offline. File-system playback still works. - [ ] Gated content (USDC / NFT) → confirm the manual-URL fallback path still signs correctly when `stream.url` is unset. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3f0b85e commit 3e40dec

1 file changed

Lines changed: 58 additions & 35 deletions

File tree

packages/mobile/src/store/offline-downloads/sagas/offlineQueueSagas/workers/downloadTrackWorker.ts

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -139,41 +139,70 @@ function* downloadTrackAsync(
139139
call(writeTrackMetadata, track)
140140
])
141141
} catch (e) {
142+
console.warn(
143+
`[offline] track ${trackId} download failed:`,
144+
e instanceof Error ? e.message : e
145+
)
142146
return OfflineDownloadStatus.ERROR
143147
}
144148

145149
return OfflineDownloadStatus.SUCCESS
146150
}
147151

152+
function buildMirrorUris(primary: string, mirrors: string[] | undefined) {
153+
const uris = [primary]
154+
for (const mirror of mirrors ?? []) {
155+
try {
156+
const url = new URL(primary)
157+
url.hostname = new URL(mirror).hostname
158+
uris.push(url.toString())
159+
} catch {
160+
// skip malformed mirror
161+
}
162+
}
163+
return uris
164+
}
165+
148166
function* downloadTrackAudio(track: UserTrackMetadata, userId: ID) {
149-
const { track_id } = track
167+
const { track_id, stream } = track
150168

151169
const trackFilePath = getLocalAudioPath(track_id)
152170

153-
const audiusSdk = yield* getContext('audiusSdk')
154-
const sdk = yield* call(audiusSdk)
155-
const audiusBackendInstance = yield* getContext('audiusBackendInstance')
156-
const nftAccessSignatureMap = yield* select(getNftAccessSignatureMap)
157-
const nftAccessSignature = nftAccessSignatureMap[track_id]?.mp3 ?? null
158-
const { data, signature } = yield* call(
159-
audiusBackendInstance.signGatedContentRequest,
160-
{ sdk }
161-
)
162-
const trackAudioUri = yield* call(
163-
[sdk.tracks, sdk.tracks.getTrackStreamUrl],
164-
{
165-
trackId: Id.parse(track_id),
166-
userId: OptionalId.parse(userId),
167-
userSignature: signature,
168-
userData: data,
169-
nftAccessSignature: nftAccessSignature
170-
? JSON.stringify(nftAccessSignature)
171-
: undefined
172-
}
173-
)
174-
const response = yield* call(downloadFile, trackAudioUri, trackFilePath)
175-
const { status } = response.info()
176-
if (status === 200) return
171+
// Prefer the v1 pre-signed stream URL (with mirrors), matching the
172+
// AudioPlayer's online-streaming logic. Only fall back to building a
173+
// URL manually when the v1 response omits stream.url.
174+
let candidateUris: string[]
175+
if (stream?.url) {
176+
candidateUris = buildMirrorUris(stream.url, stream.mirrors)
177+
} else {
178+
const sdk = yield* getSDK()
179+
const audiusBackendInstance = yield* getContext('audiusBackendInstance')
180+
const nftAccessSignatureMap = yield* select(getNftAccessSignatureMap)
181+
const nftAccessSignature = nftAccessSignatureMap[track_id]?.mp3 ?? null
182+
const { data, signature } = yield* call(
183+
audiusBackendInstance.signGatedContentRequest,
184+
{ sdk }
185+
)
186+
const fallbackUri = yield* call(
187+
[sdk.tracks, sdk.tracks.getTrackStreamUrl],
188+
{
189+
trackId: Id.parse(track_id),
190+
userId: OptionalId.parse(userId),
191+
userSignature: signature,
192+
userData: data,
193+
nftAccessSignature: nftAccessSignature
194+
? JSON.stringify(nftAccessSignature)
195+
: undefined
196+
}
197+
)
198+
candidateUris = [fallbackUri]
199+
}
200+
201+
for (const uri of candidateUris) {
202+
const response = yield* call(downloadFile, uri, trackFilePath)
203+
const { status } = response.info()
204+
if (status === 200) return
205+
}
177206

178207
throw new Error('Unable to download track audio')
179208
}
@@ -184,15 +213,7 @@ function* downloadTrackCoverArt(track: TrackMetadata) {
184213
const primaryImage = artwork[SquareSizes.SIZE_1000_BY_1000]
185214
if (!primaryImage) return
186215

187-
const coverArtUris = [
188-
primaryImage,
189-
...(artwork.mirrors ?? []).map((mirror) => {
190-
const url = new URL(primaryImage)
191-
url.hostname = new URL(mirror).hostname
192-
return url.toString()
193-
})
194-
]
195-
216+
const coverArtUris = buildMirrorUris(primaryImage, artwork.mirrors)
196217
const covertArtFilePath = getLocalTrackCoverArtDestination(track_id)
197218

198219
for (const coverArtUri of coverArtUris) {
@@ -201,7 +222,9 @@ function* downloadTrackCoverArt(track: TrackMetadata) {
201222
if (status === 200) return
202223
}
203224

204-
throw new Error('Unable to download track cover art')
225+
// Best-effort: don't fail the whole track download if cover art is
226+
// unavailable — the audio file is the essential payload, and the
227+
// collection cover art download follows the same non-fatal pattern.
205228
}
206229

207230
async function writeTrackMetadata(track: UserTrackMetadata) {

0 commit comments

Comments
 (0)