diff --git a/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts b/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts index 09627688c84..6f68c64251c 100644 --- a/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts +++ b/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useState } from 'react' import { Id } from '@audius/sdk' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -14,7 +14,13 @@ import { useTrack } from './useTrack' // Stop polling the archive job after this long even if it never transitions // out of `active`, so the UI can surface an error instead of spinning forever. -const STEMS_ARCHIVE_POLL_TIMEOUT_MS = 300_000 // 5 minutes +// +// Sized for the large end of real stem sets: a contest track can carry dozens +// of lossless stems totalling multiple GB, and WAV barely compresses, so the +// server-side zip legitimately runs for many minutes. The previous 5 minute +// budget expired before those archives could finish and reported a failure for +// a job that was still making progress. +const STEMS_ARCHIVE_POLL_TIMEOUT_MS = 900_000 // 15 minutes type GetStemsArchiveJobStatusResponse = { id: string @@ -48,9 +54,21 @@ export const useDownloadTrackStems = ({ trackId }: { trackId: ID }) => { const queryClient = useQueryClient() const { data: currentUserId } = useCurrentUserId() - // Use existing track data to get access information - const { data: trackAccess } = useTrack(trackId, { - select: (track) => track?.access + // Whether the parent track can be bundled into the archive. Two separate + // conditions, and both matter: + // - `is_downloadable` — the artist actually offers the full track. If + // this is false there is no downloadable parent file at all and its + // download URL 404s. + // - `access.download` — the *gating* check ("this user is allowed to + // download"), which is `true` for any ungated track regardless of + // whether a downloadable file exists. + // Checking only the latter asks the archiver to include a file that isn't + // there, which is how stem archives for stem-only tracks broke. + const { data: parentDownloadability } = useTrack(trackId, { + select: (track) => ({ + isDownloadable: track?.is_downloadable === true, + hasDownloadAccess: track?.access?.download === true + }) }) return useMutation({ @@ -64,8 +82,9 @@ export const useDownloadTrackStems = ({ trackId }: { trackId: ID }) => { throw new Error('Current user ID is required') } - // Use track access info to determine if parent track is downloadable - const includeParent = trackAccess?.download === true + const includeParent = + parentDownloadability?.isDownloadable === true && + parentDownloadability?.hasDownloadAccess === true return await archiver.createStemsArchive({ trackId: Id.parse(trackId), @@ -115,14 +134,14 @@ export const useGetStemsArchiveJobStatus = ( ) => { const { audiusSdk } = useQueryContext() - // Track when polling for this job began so we can enforce a hard timeout even - // if the job never transitions out of `active`. - const jobStartTimeRef = useRef(null) - useEffect(() => { - jobStartTimeRef.current = jobId ? Date.now() : null - }, [jobId]) + // Hard stop for a job that never transitions out of `active`. This has to be + // real state rather than a ref: returning `false` from `refetchInterval` + // silently stops polling without re-rendering, and the job state stays + // `active` forever, so callers had no way to tell a stalled job from an + // in-progress one and would spin indefinitely. + const [isTimedOut, setIsTimedOut] = useState(false) - return useQuery({ + const query = useQuery({ queryKey: getStemsArchiveJobQueryKey(jobId), queryFn: async () => { if (!jobId) { @@ -135,15 +154,9 @@ export const useGetStemsArchiveJobStatus = ( } return await archiver.getStemsArchiveJobStatus({ jobId }) }, - // refetch once per second until the job is completed or failed + // refetch once per second until the job is completed, failed, or we give up refetchInterval: (query) => { - // Hard stop: give up polling after the timeout even if the job is still - // reported as active, so we don't spin forever on a stalled job. - const jobStartTime = jobStartTimeRef.current - if ( - jobStartTime !== null && - Date.now() - jobStartTime > STEMS_ARCHIVE_POLL_TIMEOUT_MS - ) { + if (isTimedOut) { return false } if (!query.state.data) { @@ -159,4 +172,19 @@ export const useGetStemsArchiveJobStatus = ( enabled: !!jobId, ...options }) + + const jobState = query.data?.state + const isSettled = jobState === 'completed' || jobState === 'failed' + + useEffect(() => { + setIsTimedOut(false) + if (!jobId || isSettled) return + const timer = setTimeout( + () => setIsTimedOut(true), + STEMS_ARCHIVE_POLL_TIMEOUT_MS + ) + return () => clearTimeout(timer) + }, [jobId, isSettled]) + + return { ...query, isTimedOut } } diff --git a/packages/common/src/api/tan-query/tracks/useUpdateTrack.ts b/packages/common/src/api/tan-query/tracks/useUpdateTrack.ts index ee0f491b272..703559a690c 100644 --- a/packages/common/src/api/tan-query/tracks/useUpdateTrack.ts +++ b/packages/common/src/api/tan-query/tracks/useUpdateTrack.ts @@ -36,6 +36,7 @@ import { handleStemUpdates } from '../utils/handleStemUpdates' import { primeTrackData } from '../utils/primeTrackData' import { useDeleteTrack } from './useDeleteTrack' +import { getStemsQueryKey } from './useStems' import { getTrackQueryKey } from './useTrack' const { getCurrentUploads } = stemsUploadSelectors @@ -174,15 +175,20 @@ export const useUpdateTrack = () => { store.getState() as CommonState, trackId ) - if (previousMetadata) { - handleStemUpdates( - metadata, - previousMetadata as any, - inProgressStemUploads, - (trackId: ID) => deleteTrack({ trackId }), - dispatch - ) - } + // Server view of the track's stems. Anything not represented in the + // submitted metadata is treated as removed, so this has to come from the + // stems query rather than the cached track — the track's `_stems` field + // was never populated, which silently discarded every stem removal. + const existingStems = + queryClient.getQueryData(getStemsQueryKey(trackId)) ?? [] + handleStemUpdates( + metadata, + trackId, + existingStems, + inProgressStemUploads, + (trackId: ID) => deleteTrack({ trackId }), + dispatch + ) // New-remix analytics — replaces the legacy `trackNewRemixEvent` saga // helper. Fires when the parent_track_id changes. diff --git a/packages/common/src/api/tan-query/utils/handleStemUpdates.test.ts b/packages/common/src/api/tan-query/utils/handleStemUpdates.test.ts new file mode 100644 index 00000000000..7712c6cf97d --- /dev/null +++ b/packages/common/src/api/tan-query/utils/handleStemUpdates.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest' + +import { StemCategory } from '~/models/Stems' +import { stemsUploadActions } from '~/store/stems-upload' + +import { handleStemUpdates } from './handleStemUpdates' + +const TRACK_ID = 100 + +const existingStem = (track_id: number) => ({ track_id }) + +/** A stem already published on the track, as the edit form re-submits it. */ +const submittedExistingStem = (track_id: number) => ({ + metadata: { track_id }, + category: StemCategory.OTHER +}) + +/** A newly dropped file that hasn't been uploaded yet. */ +const submittedNewStem = (name: string) => ({ + file: new File([], name), + metadata: { title: name }, + category: StemCategory.OTHER +}) + +const run = ({ + stems, + existingStems, + inProgressStemUploads = [] +}: { + stems?: any[] + existingStems: { track_id: number }[] + inProgressStemUploads?: any[] +}) => { + const deleteTrack = vi.fn() + const dispatch = vi.fn() + handleStemUpdates( + { stems } as any, + TRACK_ID, + existingStems, + inProgressStemUploads, + deleteTrack, + dispatch + ) + return { deleteTrack, dispatch } +} + +describe('handleStemUpdates', () => { + it('deletes stems that were dropped from the submitted list', () => { + const { deleteTrack } = run({ + stems: [submittedExistingStem(1), submittedExistingStem(3)], + existingStems: [existingStem(1), existingStem(2), existingStem(3)] + }) + + expect(deleteTrack).toHaveBeenCalledTimes(1) + expect(deleteTrack).toHaveBeenCalledWith(2) + }) + + it('deletes every stem when the submitted list is empty', () => { + const { deleteTrack } = run({ + stems: [], + existingStems: [existingStem(1), existingStem(2)] + }) + + expect(deleteTrack).toHaveBeenCalledTimes(2) + expect(deleteTrack).toHaveBeenCalledWith(1) + expect(deleteTrack).toHaveBeenCalledWith(2) + }) + + it('does nothing when stems are absent from the submitted metadata', () => { + // Partial updates that never touch stems (e.g. toggling downloadability) + // must not be read as "the artist removed everything". + const { deleteTrack, dispatch } = run({ + stems: undefined, + existingStems: [existingStem(1), existingStem(2)] + }) + + expect(deleteTrack).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + }) + + it('cannot detect removals when the existing stem list is unknown', () => { + // The safe failure mode: an empty server view means we skip deletions + // rather than guessing. + const { deleteTrack } = run({ stems: [], existingStems: [] }) + + expect(deleteTrack).not.toHaveBeenCalled() + }) + + it('does not delete stems whose upload is still in flight', () => { + const { deleteTrack } = run({ + stems: [], + existingStems: [existingStem(1), existingStem(2)], + inProgressStemUploads: [{ metadata: { track_id: 2 } }] + }) + + expect(deleteTrack).toHaveBeenCalledTimes(1) + expect(deleteTrack).toHaveBeenCalledWith(1) + }) + + it('starts uploads for newly added files without re-uploading existing stems', () => { + const { dispatch } = run({ + stems: [submittedExistingStem(1), submittedNewStem('kick.wav')], + existingStems: [existingStem(1)] + }) + + expect(dispatch).toHaveBeenCalledTimes(1) + const action = dispatch.mock.calls[0][0] + expect(action.type).toBe(stemsUploadActions.startStemUploads.type) + expect(action.payload.parentId).toBe(TRACK_ID) + expect(action.payload.uploads).toHaveLength(1) + expect(action.payload.uploads[0].metadata.title).toBe('kick.wav') + }) + + it('handles a same-name replacement as one delete plus one upload', () => { + // The artist's actual workflow: swap a published stem for a smaller file + // of the same name. + const { deleteTrack, dispatch } = run({ + stems: [submittedNewStem('Pads.wav')], + existingStems: [existingStem(7)] + }) + + expect(deleteTrack).toHaveBeenCalledWith(7) + expect(dispatch.mock.calls[0][0].payload.uploads).toHaveLength(1) + }) +}) diff --git a/packages/common/src/api/tan-query/utils/handleStemUpdates.ts b/packages/common/src/api/tan-query/utils/handleStemUpdates.ts index ba44aef0207..5df9c035a64 100644 --- a/packages/common/src/api/tan-query/utils/handleStemUpdates.ts +++ b/packages/common/src/api/tan-query/utils/handleStemUpdates.ts @@ -1,38 +1,50 @@ -import { Track } from '@audius/sdk' import { Dispatch } from 'redux' import { ID } from '~/models/Identifiers' import { StemUploadWithFile } from '~/models/Stems' -import { Stem } from '~/models/Track' import { stemsUploadActions } from '~/store/stems-upload' import { TrackMetadataForUpload } from '~/store/upload' import { uuid } from '~/utils/uid' const { startStemUploads } = stemsUploadActions -type TrackWithStems = { - _stems?: Stem[] - track_id: number -} & Partial +/** + * The shape this module needs off an already-published stem. Both the + * `StemTrack`s returned by `useStems` and the lighter `Stem` records satisfy + * it. + */ +type ExistingStem = { + track_id: ID +} /** - * Handles stem updates for a track, including new uploads and removals - * @param track The track being updated with new metadata - * @param currentTrack The existing track data + * Reconciles the stem list submitted by an edit form against the stems already + * published on the track: uploads the ones that were added, deletes the ones + * that were removed. + * + * `existingStems` must be the *server* view of the track's stems (from the + * stems query), not anything derived from the submitted metadata. Passing an + * empty list means "no removals can be detected" — which is the safe failure + * mode, and what this did unconditionally when it read the never-populated + * `_stems` field off the cached track. + * + * @param metadata The track metadata being submitted + * @param trackId The track being updated — parent for any new stem uploads + * @param existingStems The stems currently published on the track * @param inProgressStemUploads Any stem uploads that are currently in progress + * @param deleteTrack Callback used to delete a removed stem's track * @param dispatch Redux dispatch function */ export const handleStemUpdates = ( metadata: Partial, - currentTrack: TrackWithStems, + trackId: ID, + existingStems: ExistingStem[], inProgressStemUploads: any[] = [], deleteTrack: (trackId: ID) => void, dispatch: Dispatch ) => { if (!metadata.stems) return - const existingStems = currentTrack._stems || [] - // Calculate stems to upload (new stems) const addedStems = metadata.stems.filter((stem) => { return !existingStems.find((existingStem) => { @@ -61,7 +73,7 @@ export const handleStemUpdates = ( if (addedStemsWithFiles.length > 0) { dispatch( startStemUploads({ - parentId: currentTrack.track_id, + parentId: trackId, uploads: addedStemsWithFiles, batchUID: uuid() }) diff --git a/packages/common/src/hooks/purchaseContent/index.ts b/packages/common/src/hooks/purchaseContent/index.ts index c209476e85d..93115e56246 100644 --- a/packages/common/src/hooks/purchaseContent/index.ts +++ b/packages/common/src/hooks/purchaseContent/index.ts @@ -3,6 +3,7 @@ export * from './useChallengeCooldownSchedule' export * from './useUSDCPurchaseConfig' export * from './usePurchaseContentErrorMessage' export * from './usePayExtraPresets' +export * from './usePurchaseableStemCount' export * from './utils' export * from './types' export * from './constants' diff --git a/packages/common/src/hooks/purchaseContent/usePurchaseableStemCount.ts b/packages/common/src/hooks/purchaseContent/usePurchaseableStemCount.ts new file mode 100644 index 00000000000..e85f408ba54 --- /dev/null +++ b/packages/common/src/hooks/purchaseContent/usePurchaseableStemCount.ts @@ -0,0 +1,25 @@ +import { useStems } from '~/api/tan-query/tracks/useStems' + +import { PurchaseableContentMetadata } from './types' + +/** + * Number of stems included in a download-gated purchase, used to itemize what + * the buyer is paying for. + * + * Reads the stems query rather than anything on the metadata object: the + * `_stems` field this previously used was never populated anywhere in the app, + * so every purchase flow reported zero stems. Albums have no stems of their + * own and resolve to 0. + */ +export const usePurchaseableStemCount = ( + metadata: PurchaseableContentMetadata | undefined +) => { + const trackId = + metadata && 'track_id' in metadata ? metadata.track_id : undefined + const { data: stems } = useStems(trackId) + + const isDownloadGated = + !!metadata && 'is_download_gated' in metadata && metadata.is_download_gated + + return isDownloadGated ? (stems?.length ?? 0) : 0 +} diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts index f2ca228d72a..b5103982ea3 100644 --- a/packages/common/src/models/Track.ts +++ b/packages/common/src/models/Track.ts @@ -302,7 +302,6 @@ export type ComputedTrackProperties = { _followees?: Followee[] _marked_deleted?: boolean _is_publishing?: boolean - _stems?: Stem[] // Present iff remixes have been fetched for a track _remixes?: Array<{ track_id: ID }> diff --git a/packages/mobile/src/components/premium-content-purchase-drawer/PremiumContentPurchaseDrawer.tsx b/packages/mobile/src/components/premium-content-purchase-drawer/PremiumContentPurchaseDrawer.tsx index c9e8713d0b4..219d9ec0521 100644 --- a/packages/mobile/src/components/premium-content-purchase-drawer/PremiumContentPurchaseDrawer.tsx +++ b/packages/mobile/src/components/premium-content-purchase-drawer/PremiumContentPurchaseDrawer.tsx @@ -18,6 +18,7 @@ import { isStreamPurchaseable, isTrackDownloadPurchaseable, isContentDownloadGated, + usePurchaseableStemCount, PURCHASE_METHOD_MINT_ADDRESS, useFeatureFlag } from '@audius/common/hooks' @@ -319,9 +320,7 @@ const RenderForm = ({ } }, [setPurchaseVendor, showCoinflow, purchaseVendor]) - const stemsPurchaseCount = isContentDownloadGated(content) - ? (content._stems?.length ?? 0) - : 0 + const stemsPurchaseCount = usePurchaseableStemCount(content) const downloadPurchaseCount = isContentDownloadGated(content) && content.is_downloadable ? 1 : 0 const streamPurchaseCount = content.is_stream_gated ? 1 : 0 diff --git a/packages/mobile/src/components/premium-content-purchase-drawer/PurchaseSuccess.tsx b/packages/mobile/src/components/premium-content-purchase-drawer/PurchaseSuccess.tsx index 8a1ef2380cb..a0ea0c8e2b7 100644 --- a/packages/mobile/src/components/premium-content-purchase-drawer/PurchaseSuccess.tsx +++ b/packages/mobile/src/components/premium-content-purchase-drawer/PurchaseSuccess.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { isPurchaseableAlbum, + usePurchaseableStemCount, type PurchaseableContentMetadata } from '@audius/common/hooks' import { @@ -54,12 +55,14 @@ export const PurchaseSuccess = ({ ? getCollectionRoute(metadata) : getTrackRoute(metadata, true) + const stemCount = usePurchaseableStemCount(metadata) + const dispatch = useDispatch() const handleXShare = useCallback( (handle: string) => { let shareText: string - if (!isAlbum && metadata.is_download_gated && metadata._stems?.length) { + if (!isAlbum && metadata.is_download_gated && stemCount > 0) { shareText = messages.shareXText('stems for', title, handle) } else { shareText = messages.shareXText( @@ -76,7 +79,7 @@ export const PurchaseSuccess = ({ } as const } }, - [isAlbum, metadata, title] + [isAlbum, metadata, title, stemCount] ) const onRepost = useCallback(() => { diff --git a/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx b/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx index 7ee712b057d..08bf3bdf299 100644 --- a/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx +++ b/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx @@ -73,16 +73,19 @@ const DownloadTrackArchiveModalContent = ({ const { mutate: cancelStemsArchiveJob } = useCancelStemsArchiveJob() - const { data: jobStatus, isError: isJobStatusError } = - useGetStemsArchiveJobStatus({ - jobId - }) + const { + data: jobStatus, + isError: isJobStatusError, + isTimedOut: isJobTimedOut + } = useGetStemsArchiveJobStatus({ + jobId + }) const hasError = !isStartingDownload && (initiateDownloadFailed || jobStatus?.state === 'failed' || - (!!jobId && isJobStatusError)) + (!!jobId && (isJobStatusError || isJobTimedOut))) useEffect(() => { if (hasError) { diff --git a/packages/web/src/components/premium-content-purchase-modal/pages/GuestCheckoutPage.tsx b/packages/web/src/components/premium-content-purchase-modal/pages/GuestCheckoutPage.tsx index da6b81853a0..12b1fc18bb4 100644 --- a/packages/web/src/components/premium-content-purchase-modal/pages/GuestCheckoutPage.tsx +++ b/packages/web/src/components/premium-content-purchase-modal/pages/GuestCheckoutPage.tsx @@ -1,3 +1,4 @@ +import { usePurchaseableStemCount } from '@audius/common/hooks' import { PurchaseableContentMetadata } from '@audius/common/src/hooks/purchaseContent/types' import { isPurchaseableAlbum } from '@audius/common/src/hooks/purchaseContent/utils' import { SIGN_IN_PAGE } from '@audius/common/src/utils/route' @@ -33,10 +34,7 @@ export const GuestCheckoutPage = (props: GuestCheckoutProps) => { }) const isMobile = useIsMobile() const isAlbumPurchase = isPurchaseableAlbum(metadata) - const stemsPurchaseCount = - 'is_download_gated' in metadata && metadata.is_download_gated - ? (metadata._stems?.length ?? 0) - : 0 + const stemsPurchaseCount = usePurchaseableStemCount(metadata) const downloadPurchaseCount = 'is_download_gated' in metadata && metadata.is_download_gated && diff --git a/packages/web/src/components/premium-content-purchase-modal/pages/PurchaseContentPage.tsx b/packages/web/src/components/premium-content-purchase-modal/pages/PurchaseContentPage.tsx index 6006186134c..a73c171e1c1 100644 --- a/packages/web/src/components/premium-content-purchase-modal/pages/PurchaseContentPage.tsx +++ b/packages/web/src/components/premium-content-purchase-modal/pages/PurchaseContentPage.tsx @@ -10,6 +10,7 @@ import { usePurchaseMethod, PurchaseableContentMetadata, isPurchaseableAlbum, + usePurchaseableStemCount, PURCHASE_METHOD_MINT_ADDRESS } from '@audius/common/hooks' import { PurchaseMethod, PurchaseVendor } from '@audius/common/models' @@ -99,10 +100,7 @@ export const PurchaseContentPage = (props: PurchaseContentPageProps) => { }, [handleChangeVendor, showCoinflow, purchaseVendor]) const isAlbumPurchase = isPurchaseableAlbum(metadata) - const stemsPurchaseCount = - 'is_download_gated' in metadata && metadata.is_download_gated - ? (metadata._stems?.length ?? 0) - : 0 + const stemsPurchaseCount = usePurchaseableStemCount(metadata) const downloadPurchaseCount = 'is_download_gated' in metadata && metadata.is_download_gated && diff --git a/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx b/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx index 46f95018033..ad126d29a9b 100644 --- a/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx +++ b/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx @@ -183,11 +183,17 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => { // Action: per-row download. Mirrors the `handleDownload` path on // DownloadSection — opens the wait-for-download modal with the // specific trackId the user clicked. + // + // Deliberately does NOT pass `parentTrackId`: the download saga appends + // the parent to the file list (`[...trackIds, parentTrackId]`), so doing + // so would pull the full track down alongside every single-stem download. + // When the parent isn't downloadable its download URL 404s and takes the + // whole batch with it. `DownloadRow` on the track page has always called + // this with just the row's own id — match it. const handleDownloadOne = useRequiresAccountCallback( - (downloadTrackId: ID, parentTrackId?: ID) => { + (downloadTrackId: ID) => { followContestIfNeeded() openWaitForDownloadModal({ - parentTrackId, trackIds: [downloadTrackId], quality: DownloadQuality.ORIGINAL }) @@ -368,7 +374,7 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => { title={row.title} subtitle={row.subtitle} size={row.size} - onDownload={() => handleDownloadOne(row.trackId, trackId)} + onDownload={() => handleDownloadOne(row.trackId)} /> ))} diff --git a/packages/web/src/pages/edit-page/EditTrackPage.tsx b/packages/web/src/pages/edit-page/EditTrackPage.tsx index 06a502fb2bd..a0abcd606e8 100644 --- a/packages/web/src/pages/edit-page/EditTrackPage.tsx +++ b/packages/web/src/pages/edit-page/EditTrackPage.tsx @@ -56,7 +56,13 @@ export const EditTrackPage = (props: EditPageProps) => { const { data: track, status: trackStatus } = useTrackByParams(params) - const { data: stemTracks = [] } = useStems(track?.track_id) + // The form must not initialize until the stems query has settled. Its stem + // list is the source of truth for the reconcile on save, so rendering early + // would seed the form with an empty list and delete every published stem on + // the first submit. + const { data: stemTracks = [], isPending: isStemsPending } = useStems( + track?.track_id + ) const onSubmit = async (formValues: TrackEditFormValues) => { const metadata = { ...formValues.trackMetadatas[0] } @@ -149,7 +155,7 @@ export const EditTrackPage = (props: EditPageProps) => { /> } > - {trackStatus !== 'success' || !coverArtUrl ? ( + {trackStatus !== 'success' || !coverArtUrl || isStemsPending ? ( ) : ( diff --git a/packages/web/src/services/track-download.ts b/packages/web/src/services/track-download.ts index a980972c8af..60db2613be4 100644 --- a/packages/web/src/services/track-download.ts +++ b/packages/web/src/services/track-download.ts @@ -51,28 +51,64 @@ class TrackDownload extends TrackDownloadBase { dispatch(beginDownload()) dedupFilenames(files) - const responsePromises = files.map( - async ({ url }) => await window.fetch(url, { signal: abortSignal }) - ) try { - const responses = await Promise.all(responsePromises) - if (!responses.every((response) => response.ok)) { + const results = await Promise.allSettled( + files.map(({ url }) => window.fetch(url, { signal: abortSignal })) + ) + + // `allSettled` swallows the abort rejection, so check for it explicitly + // and rethrow in the shape the catch below expects. + if (abortSignal?.aborted) { + const abortError = new Error('Download aborted') + abortError.name = 'AbortError' + throw abortError + } + + // Download whatever is actually available rather than failing the whole + // batch on one bad file. A single unavailable track — most commonly a + // parent whose `is_downloadable` is false, whose download URL 404s — + // used to take every other file down with it. + const available: { file: DownloadFile; response: Response }[] = [] + const skipped: string[] = [] + + results.forEach((result, i) => { + const file = files[i] + if (result.status === 'fulfilled' && result.value.ok) { + available.push({ file, response: result.value }) + } else { + const reason = + result.status === 'fulfilled' + ? `HTTP ${result.value.status}` + : ((result.reason as Error)?.message ?? 'request failed') + skipped.push(`${file.filename} (${reason})`) + } + }) + + if (skipped.length > 0) { + console.warn( + `Skipping ${skipped.length} of ${files.length} unavailable file(s) during download: ${skipped.join(', ')}` + ) + } + + // Only a batch where nothing at all could be fetched is a failure. + if (available.length === 0) { throw new Error('Download unsuccessful') } - const filename = rootDirectoryName ?? files[0].filename + + const filename = rootDirectoryName ?? available[0].file.filename let url - if (files.length === 1) { - url = responses[0].url + if (available.length === 1) { + url = available[0].response.url } else { if (!rootDirectoryName) throw new Error( 'rootDirectory must be supplied when downloading multiple files' ) const blob = await downloadZip( - responses.map((r, i) => { + available.map(({ file, response }) => { return { - name: rootDirectoryName + '/' + files[i].filename, - input: r + name: rootDirectoryName + '/' + file.filename, + input: response } }) ).blob() @@ -83,7 +119,7 @@ class TrackDownload extends TrackDownloadBase { // Track download success event const eventName = - files.length === 1 + available.length === 1 ? Name.TRACK_DOWNLOAD_SUCCESSFUL_DOWNLOAD_SINGLE : Name.TRACK_DOWNLOAD_SUCCESSFUL_DOWNLOAD_ALL trackEvent(eventName, { device: 'web' })