diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 431c75327b8..36bff7afaa3 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -90,7 +90,6 @@ export * from './tan-query/search/usePopularGenres' // Tracks export * from './tan-query/tracks/useDeleteTrack' -export * from './tan-query/tracks/useDownloadTrackStems' export * from './tan-query/tracks/useTrackDownloadCounts' export * from './tan-query/tracks/useFavoriteTrack' export * from './tan-query/tracks/useAcceptTrackCollaboration' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index 4e3715d502c..0466f38ec04 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -11,8 +11,6 @@ export const QUERY_KEYS = { comment: 'comment', commentReplies: 'commentReplies', computedProps: 'computedProps', - downloadTrackStems: 'downloadTrackStems', - stemsArchiveJob: 'stemsArchiveJob', exploreContent: 'exploreContent', trackCommentNotificationSetting: 'trackCommentNotificationSetting', trackCommentCount: 'trackCommentCount', diff --git a/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts b/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts deleted file mode 100644 index 6f68c64251c..00000000000 --- a/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { useEffect, useState } from 'react' - -import { Id } from '@audius/sdk' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' - -import { useQueryContext } from '~/api/tan-query/utils' -import { ID } from '~/models/Identifiers' - -import { QUERY_KEYS } from '../queryKeys' -import { QueryKey, QueryOptions } from '../types' -import { useCurrentUserId } from '../users/account/useCurrentUserId' - -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. -// -// 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 - state: - | 'completed' - | 'failed' - | 'active' - | 'waiting' - | 'delayed' - | 'prioritized' - progress?: number - failedReason?: string -} - -export const getStemsArchiveJobQueryKey = (jobId?: string) => { - return [ - QUERY_KEYS.stemsArchiveJob, - jobId - ] as unknown as QueryKey -} - -export const getDownloadTrackStemsQueryKey = (trackId: ID) => { - return [ - QUERY_KEYS.downloadTrackStems, - trackId - ] as unknown as QueryKey -} - -export const useDownloadTrackStems = ({ trackId }: { trackId: ID }) => { - const { audiusSdk } = useQueryContext() - const queryClient = useQueryClient() - const { data: currentUserId } = useCurrentUserId() - - // 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({ - mutationFn: async () => { - const sdk = await audiusSdk() - const archiver = sdk.services.archiverService - if (!archiver) { - throw new Error('Archiver service not configured') - } - if (!currentUserId) { - throw new Error('Current user ID is required') - } - - const includeParent = - parentDownloadability?.isDownloadable === true && - parentDownloadability?.hasDownloadAccess === true - - return await archiver.createStemsArchive({ - trackId: Id.parse(trackId), - userId: Id.parse(currentUserId), - includeParent - }) - }, - onSuccess: async (response) => { - queryClient.setQueryData(getDownloadTrackStemsQueryKey(trackId), response) - queryClient.setQueryData( - getStemsArchiveJobQueryKey(response.id), - response - ) - }, - onError: (error) => { - console.error(error) - } - }) -} - -export const useCancelStemsArchiveJob = () => { - const { audiusSdk } = useQueryContext() - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ jobId }: { jobId: string }) => { - const sdk = await audiusSdk() - const archiver = sdk.services.archiverService - if (!archiver) { - throw new Error('Archiver service not configured') - } - await archiver.cancelStemsArchiveJob({ jobId }) - return jobId - }, - onSuccess: (jobId) => { - queryClient.removeQueries({ - queryKey: getStemsArchiveJobQueryKey(jobId), - exact: true - }) - } - }) -} - -export const useGetStemsArchiveJobStatus = ( - { jobId }: { jobId?: string }, - options?: QueryOptions -) => { - const { audiusSdk } = useQueryContext() - - // 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) - - const query = useQuery({ - queryKey: getStemsArchiveJobQueryKey(jobId), - queryFn: async () => { - if (!jobId) { - throw new Error('Job ID is required') - } - const sdk = await audiusSdk() - const archiver = sdk.services.archiverService - if (!archiver) { - throw new Error('Archiver service not configured') - } - return await archiver.getStemsArchiveJobStatus({ jobId }) - }, - // refetch once per second until the job is completed, failed, or we give up - refetchInterval: (query) => { - if (isTimedOut) { - return false - } - if (!query.state.data) { - return 1000 - } - if (['completed', 'failed'].includes(query.state.data.state)) { - return false - } - return 1000 - }, - staleTime: 0, - gcTime: 0, - 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/services/env.ts b/packages/common/src/services/env.ts index fd130b09955..90c5764fdc4 100644 --- a/packages/common/src/services/env.ts +++ b/packages/common/src/services/env.ts @@ -9,7 +9,6 @@ export type Env = { API_URL: string APP_NAME: string API_KEY: string - ARCHIVE_ENDPOINT: string AUDIUS_NETWORK_CHAIN_ID: number AUDIUS_URL: string BITSKI_CALLBACK_URL: string diff --git a/packages/common/src/store/ui/modals/download-track-archive-modal/index.ts b/packages/common/src/store/ui/modals/download-track-archive-modal/index.ts deleted file mode 100644 index 48e434067f0..00000000000 --- a/packages/common/src/store/ui/modals/download-track-archive-modal/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ID } from '~/models/Identifiers' - -import { createModal } from '../createModal' - -export type DownloadTrackArchiveModalState = { - trackId: ID | null - fileCount: number -} - -const downloadTrackArchiveModal = createModal({ - reducerPath: 'DownloadTrackArchive', - initialState: { - isOpen: false, - trackId: null, - fileCount: 1 - }, - sliceSelector: (state) => state.ui.modals -}) - -export const { - hook: useDownloadTrackArchiveModal, - reducer: downloadTrackArchiveModalReducer, - actions: downloadTrackArchiveModalActions -} = downloadTrackArchiveModal diff --git a/packages/common/src/store/ui/modals/index.ts b/packages/common/src/store/ui/modals/index.ts index 07d9d75e9d3..71af29f3d8b 100644 --- a/packages/common/src/store/ui/modals/index.ts +++ b/packages/common/src/store/ui/modals/index.ts @@ -33,7 +33,6 @@ export * from './external-wallet-sign-up-modal' export * from './connected-wallets-modal' export * from './announcement-modal' export * from './notification-modal' -export * from './download-track-archive-modal' export * from './buy-sell-modal' export * from './claim-vested-coins-modal' export * from './host-remix-contest-modal' diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index 9a289409a03..431c81ecf93 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -76,7 +76,6 @@ export const initialState: BasicModalsState = { ConnectedWallets: { isOpen: false }, Announcement: { isOpen: false }, Notification: { isOpen: false }, - DownloadTrackArchive: { isOpen: false }, BuySellModal: { isOpen: false }, HostRemixContest: { isOpen: false }, FinalizeWinnersConfirmation: { isOpen: false }, diff --git a/packages/common/src/store/ui/modals/reducers.ts b/packages/common/src/store/ui/modals/reducers.ts index 1462d93e6b3..2fd91bfaa31 100644 --- a/packages/common/src/store/ui/modals/reducers.ts +++ b/packages/common/src/store/ui/modals/reducers.ts @@ -14,7 +14,6 @@ import { chatBlastModalReducer } from './create-chat-blast-modal' import { createChatModalReducer } from './create-chat-modal' import { createPlaylistModalReducer } from './create-playlist-modal' import { deleteTrackConfirmationModalReducer } from './delete-track-confirmation-modal' -import { downloadTrackArchiveModalReducer } from './download-track-archive-modal' import { duplicatePlaylistModalReducer } from './duplicate-playlist-modal' import { earlyReleaseConfirmationModalReducer } from './early-release-confirmation-modal' import { editAccessConfirmationModalReducer } from './edit-access-confirmation-modal' @@ -90,7 +89,6 @@ const combinedReducers = combineReducers({ ConnectedWallets: connectedWalletsModalReducer, Announcement: announcementModalReducer, Notification: notificationModalReducer, - DownloadTrackArchive: downloadTrackArchiveModalReducer, HostRemixContest: hostRemixContestModalReducer, FinalizeWinnersConfirmation: finalizeWinnersConfirmationModalReducer, ReceiveTokensModal: receiveTokensModalReducer, diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index d55a3baad0b..a8255b10c2c 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -13,7 +13,6 @@ import { CoinflowOnrampModalState } from './coinflow-onramp-modal' import { CoinflowWithdrawModalState } from './coinflow-withdraw-modal' import { ChatBlastModalState } from './create-chat-blast-modal' import { DeleteTrackConfirmationModalState } from './delete-track-confirmation-modal' -import { DownloadTrackArchiveModalState } from './download-track-archive-modal' import { EarlyReleaseConfirmationModalState } from './early-release-confirmation-modal' import { EditAccessConfirmationModalState } from './edit-access-confirmation-modal' import { FinalizeWinnersConfirmationModalState } from './finalize-winners-confirmation-modal' @@ -112,7 +111,6 @@ export type Modals = | 'ConnectedWallets' | 'Announcement' | 'Notification' - | 'DownloadTrackArchive' | 'BuySellModal' | 'HostRemixContest' | 'ReceiveTokensModal' @@ -158,7 +156,6 @@ export type StatefulModalsState = { FinalizeWinnersConfirmation: FinalizeWinnersConfirmationModalState Announcement: AnnouncementModalState Notification: BaseModalState - DownloadTrackArchive: DownloadTrackArchiveModalState BuySellModal: BuySellModalState HostRemixContest: HostRemixContestModalState ReceiveTokensModal: ReceiveTokensModalState diff --git a/packages/common/src/utils/fileUtil.ts b/packages/common/src/utils/fileUtil.ts index 24e97e1e9a9..6583a2d36b8 100644 --- a/packages/common/src/utils/fileUtil.ts +++ b/packages/common/src/utils/fileUtil.ts @@ -1,7 +1,6 @@ import { Buffer } from 'buffer' import { Track, User } from '~/models' -import { DownloadFile } from '~/services' /** Convert a base64 string to a file object */ export const dataURLtoFile = async ( @@ -66,7 +65,10 @@ export const getFilename = ({ return filename } -export const dedupFilenames = (files: DownloadFile[]) => { +// Only reads and rewrites `filename`, so it accepts anything carrying one — +// callers that dedup before they have a URL to pair it with shouldn't have to +// invent a placeholder to satisfy `DownloadFile`. +export const dedupFilenames = (files: { filename: string }[]) => { const filenameCounts = new Map() for (const file of files) { const count = filenameCounts.get(file.filename) ?? 0 diff --git a/packages/mobile/src/app/Drawers.tsx b/packages/mobile/src/app/Drawers.tsx index f3e6179155f..2f4ae3fc724 100644 --- a/packages/mobile/src/app/Drawers.tsx +++ b/packages/mobile/src/app/Drawers.tsx @@ -19,7 +19,6 @@ import { CreateChatActionsDrawer } from 'app/components/create-chat-actions-draw import { DeactivateAccountConfirmationDrawer } from 'app/components/deactivate-account-confirmation-drawer' import { DeleteChatDrawer } from 'app/components/delete-chat-drawer' import { DeletePlaylistConfirmationDrawer } from 'app/components/delete-playlist-confirmation-drawer' -import { DownloadTrackArchiveDrawer } from 'app/components/download-track-archive-drawer/DownloadTrackArchiveDrawer' import { ArtistPickConfirmationDrawer } from 'app/components/drawers/ArtistPickConfirmationDrawer' import { MuteCommentsConfirmationDrawer } from 'app/components/drawers/MuteCommentsConfirmationDrawer' import { DuplicateAddConfirmationDrawer } from 'app/components/duplicate-add-confirmation-drawer' @@ -141,7 +140,6 @@ const commonDrawersMap: { [Modal in Modals]?: ComponentType } = { ReplaceTrackProgress: ReplaceTrackProgressDrawer, EarlyReleaseConfirmation: EarlyReleaseConfirmationDrawer, ArtistPick: ArtistPickConfirmationDrawer, - DownloadTrackArchive: DownloadTrackArchiveDrawer, HostRemixContest: HostRemixContestDrawer, WithdrawUSDCModal: WithdrawUSDCDrawer, ReceiveTokensModal: ReceiveTokensDrawer, diff --git a/packages/mobile/src/components/download-track-archive-drawer/DownloadTrackArchiveDrawer.tsx b/packages/mobile/src/components/download-track-archive-drawer/DownloadTrackArchiveDrawer.tsx deleted file mode 100644 index 269caeded20..00000000000 --- a/packages/mobile/src/components/download-track-archive-drawer/DownloadTrackArchiveDrawer.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' - -import { - useCancelStemsArchiveJob, - useDownloadTrackStems, - useGetStemsArchiveJobStatus, - useTrack -} from '@audius/common/api' -import { useAppContext } from '@audius/common/context' -import type { ID } from '@audius/common/models' -import { Name } from '@audius/common/models' -import type { DownloadFile } from '@audius/common/services' -import { useDownloadTrackArchiveModal } from '@audius/common/store' - -import { - Flex, - Hint, - IconError, - IconFolder, - IconReceive, - Text, - TextLink -} from '@audius/harmony-native' -import Drawer from 'app/components/drawer' -import { env } from 'app/services/env' - -import { DrawerHeader } from '../core/DrawerHeader' -import LoadingSpinner from '../loading-spinner' - -const messages = { - title: 'Downloading...', - zippingFiles: (count: number) => `Zipping files (${count})`, - error: 'Something went wrong. Please check your connection and try again.', - tryAgain: 'Try again.' -} - -const useDownloadFile = () => { - const { trackDownload } = useAppContext() - const [fetching, setFetching] = useState(false) - const [success, setSuccess] = useState(false) - const [error, setError] = useState(null) - const [abortController, setAbortController] = - useState(null) - - const downloadFile = useCallback( - async ({ file }: { file: DownloadFile }) => { - setFetching(true) - setError(null) - const abortController = new AbortController() - setAbortController(abortController) - try { - await trackDownload.downloadFile({ - file, - mimeType: 'application/zip', - abortSignal: abortController.signal - }) - setSuccess(true) - } catch (e) { - setError(e as Error) - console.error('Failed to download track archive', e as Error) - } finally { - setFetching(false) - setAbortController(null) - } - }, - [trackDownload] - ) - - const cancel = useCallback(() => { - if (abortController) { - abortController.abort() - } - }, [abortController]) - - const reset = useCallback(() => { - setFetching(false) - setSuccess(false) - setError(null) - }, []) - - return { downloadFile, fetching, success, error, reset, cancel } -} - -type DownloadTrackArchiveDrawerContentProps = { - trackId: ID - fileCount: number - isOpen: boolean - onClose: () => void - onClosed: () => void -} - -const DownloadTrackArchiveDrawerContent = ({ - trackId, - fileCount, - isOpen, - onClose, - onClosed -}: DownloadTrackArchiveDrawerContentProps) => { - const { - analytics: { track, make } - } = useAppContext() - - const { data: trackTitle } = useTrack(trackId, { - select: (track) => track.title - }) - const [step, setStep] = useState<'downloading' | 'zipping'>('zipping') - - const { - mutate: downloadTrackStems, - isError: initiateDownloadFailed, - isPending: isStartingDownload, - data: { id: jobId } = {} - } = useDownloadTrackStems({ - trackId - }) - - const { - downloadFile, - success: downloadSuccess, - error: downloadError, - reset: resetDownload, - cancel: cancelDownload - } = useDownloadFile() - - const { mutate: cancelStemsArchiveJob } = useCancelStemsArchiveJob() - - const { - data: jobState, - isError: isJobStatusError, - isTimedOut: isJobTimedOut - } = useGetStemsArchiveJobStatus({ - jobId - }) - - // `isTimedOut` and `isError` have to be part of this, not just `failed`. - // A job that never leaves `waiting` — the archiver worker losing its Redis - // lock and holding every concurrency slot, for instance — is reported as a - // perfectly valid non-terminal state forever, so keying only off `failed` - // leaves the drawer spinning with no error and no retry until the user - // gives up. The shared hook already enforces STEMS_ARCHIVE_POLL_TIMEOUT_MS - // and hands back `isTimedOut`; web consumes it and mobile did not. - const hasError = - !isStartingDownload && - (downloadError || - initiateDownloadFailed || - jobState?.state === 'failed' || - (!!jobId && (isJobStatusError || isJobTimedOut))) - - useEffect(() => { - if (hasError) { - track( - make({ - eventName: Name.TRACK_DOWNLOAD_FAILED_DOWNLOAD_ALL - }) - ) - } - }, [hasError, track, make]) - - useEffect(() => { - downloadTrackStems() - }, [downloadTrackStems]) - - useEffect(() => { - if (jobState?.state === 'completed' && trackTitle) { - const fetchResult = async () => { - setStep('downloading') - await downloadFile({ - file: { - url: `${env.ARCHIVE_ENDPOINT}/archive/stems/download/${jobId}`, - filename: `${trackTitle}.zip` - } - }) - track( - make({ - eventName: Name.TRACK_DOWNLOAD_SUCCESSFUL_DOWNLOAD_ALL - }) - ) - onClose() - } - fetchResult() - } - }, [jobState, onClose, jobId, downloadFile, trackTitle, make, track]) - - // Close drawer automatically if download was successful - useEffect(() => { - if (downloadSuccess) { - onClose() - } - }, [downloadSuccess, onClose]) - - const handleClose = useCallback(() => { - if (jobId) { - cancelStemsArchiveJob({ jobId }) - } - cancelDownload() - onClose() - }, [onClose, jobId, cancelStemsArchiveJob, cancelDownload]) - - const handleRetry = useCallback(() => { - setStep('zipping') - resetDownload() - downloadTrackStems() - }, [downloadTrackStems, resetDownload]) - - return ( - - - - - - {step === 'zipping' ? ( - <> - - - {messages.zippingFiles(fileCount)} - - - ) : ( - - {`${trackTitle}.zip`} - - )} - - {hasError ? ( - - {messages.tryAgain} - - } - > - {messages.error} - - ) : ( - - )} - - - - ) -} - -export const DownloadTrackArchiveDrawer = () => { - const { - data: { trackId, fileCount }, - isOpen, - onClose, - onClosed - } = useDownloadTrackArchiveModal() - - if (!trackId) { - console.error( - 'Unexpected missing trackId when rendering DownloadTrackArchiveDrawer' - ) - return null - } - - return ( - - ) -} diff --git a/packages/mobile/src/components/download-track-archive-drawer/index.ts b/packages/mobile/src/components/download-track-archive-drawer/index.ts deleted file mode 100644 index 39c961e9d40..00000000000 --- a/packages/mobile/src/components/download-track-archive-drawer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DownloadTrackArchiveDrawer } from './DownloadTrackArchiveDrawer' diff --git a/packages/mobile/src/screens/track-screen/DownloadSection.tsx b/packages/mobile/src/screens/track-screen/DownloadSection.tsx index ccaf7376a19..16dcbc63615 100644 --- a/packages/mobile/src/screens/track-screen/DownloadSection.tsx +++ b/packages/mobile/src/screens/track-screen/DownloadSection.tsx @@ -6,7 +6,6 @@ import type { ID } from '@audius/common/models' import { DownloadQuality, ModalSource } from '@audius/common/models' import { PurchaseableContentType, - useDownloadTrackArchiveModal, usePremiumContentPurchaseModal, useWaitForDownloadModal } from '@audius/common/store' @@ -128,28 +127,28 @@ export const DownloadSection = ({ trackId }: { trackId: ID }) => { ] ) - const { onOpen: openDownloadTrackArchiveModal } = - useDownloadTrackArchiveModal() - + // Download All goes through the same saga as a single-row download, which + // fetches each file and zips it on-device. The server-side archiver it used + // to call queued a job that regularly never got picked up, stranding users + // on a spinner; zipping locally has no queue to stall on. Native keeps the + // single-archive result because there's no browser here to hand a batch of + // individual downloads to. const handleDownloadAll = useCallback(() => { if (shouldDisplayDownloadFollowGated) { toast({ content: messages.followToDownload }) return } - // Only include parent track in count if it's downloadable - const parentTrackCount = track?.access?.download ? 1 : 0 - openDownloadTrackArchiveModal({ - trackId, - fileCount: stemTracks.length + parentTrackCount + handleDownload({ + trackIds: stemTracks.map((s) => s.track_id), + parentTrackId: trackId }) }, [ - openDownloadTrackArchiveModal, + handleDownload, shouldDisplayDownloadFollowGated, - stemTracks.length, + stemTracks, toast, - trackId, - track?.access?.download + trackId ]) const hasStems = stemTracks.length > 0 diff --git a/packages/mobile/src/services/env/env.dev.ts b/packages/mobile/src/services/env/env.dev.ts index ebd1a8021b4..70d70a05462 100644 --- a/packages/mobile/src/services/env/env.dev.ts +++ b/packages/mobile/src/services/env/env.dev.ts @@ -8,7 +8,6 @@ export const env: Env = { API_KEY: '2dc52ec9a4c31790cab6653de0c637f680faa993', API_URL: process.env.VITE_API_URL ?? 'http://audius-api', APP_NAME: 'audius-client', - ARCHIVE_ENDPOINT: process.env.VITE_API_URL ?? 'http://audius-api', AUDIUS_NETWORK_CHAIN_ID: 1337, AUDIUS_URL: 'https://audius.co', BITSKI_CALLBACK_URL: 'https://audius.co/bitski-callback.html', diff --git a/packages/mobile/src/services/env/env.prod.ts b/packages/mobile/src/services/env/env.prod.ts index 635251d6c8f..ed183e744d3 100644 --- a/packages/mobile/src/services/env/env.prod.ts +++ b/packages/mobile/src/services/env/env.prod.ts @@ -8,7 +8,6 @@ export const env: Env = { API_KEY: '8acf5eb7436ea403ee536a7334faa5e9ada4b50f', API_URL: process.env.VITE_API_URL ?? 'https://api.audius.co', APP_NAME: 'audius-client', - ARCHIVE_ENDPOINT: 'https://api.audius.co', AUDIUS_NETWORK_CHAIN_ID: 31524, AUDIUS_URL: 'https://audius.co', BITSKI_CALLBACK_URL: 'https://audius.co/bitski-callback.html', diff --git a/packages/mobile/src/services/sdk/audius-sdk.ts b/packages/mobile/src/services/sdk/audius-sdk.ts index 707a13ad216..63c5739a372 100644 --- a/packages/mobile/src/services/sdk/audius-sdk.ts +++ b/packages/mobile/src/services/sdk/audius-sdk.ts @@ -4,8 +4,7 @@ import { Configuration, SolanaRelay, createSdkWithServices, - type AudiusSdkWithServices, - ArchiverService + type AudiusSdkWithServices } from '@audius/sdk' import { env } from 'app/services/env' @@ -40,21 +39,6 @@ const initSdk = async () => { }) ) - const archiverService = new ArchiverService( - new Configuration({ - basePath: '/archive', - middleware: [ - { - pre: async (context) => { - const endpoint = env.ARCHIVE_ENDPOINT - const url = `${endpoint}${context.url}` - return { url, init: context.init } - } - } - ] - }) - ) - // Overrides some DN configuration from optimizely const audiusWalletClient = await getAudiusWalletClient() @@ -64,8 +48,7 @@ const initSdk = async () => { environment: env.ENVIRONMENT, services: { solanaRelay, - audiusWalletClient, - archiverService + audiusWalletClient } }) sdkInstance = audiusSdk diff --git a/packages/sdk/src/sdk/createSdkWithServices.ts b/packages/sdk/src/sdk/createSdkWithServices.ts index 3f62c3e7d31..13776c12b33 100644 --- a/packages/sdk/src/sdk/createSdkWithServices.ts +++ b/packages/sdk/src/sdk/createSdkWithServices.ts @@ -239,16 +239,6 @@ const initializeServices = ({ }) ) - const archiverService = config.services?.archiverService - ? config.services.archiverService.withMiddleware( - addRequestSignatureMiddleware({ - services: { audiusWalletClient, logger }, - apiKey, - apiSecret - }) - ) - : undefined - const emailEncryptionService = config.services?.emailEncryptionService ?? new EmailEncryptionService( @@ -338,7 +328,6 @@ const initializeServices = ({ solanaRelay, antiAbuseOracle, emailEncryptionService, - archiverService, logger, tokenStore: config.services?.tokenStore ?? new TokenStoreLocalStorage() } diff --git a/packages/sdk/src/sdk/services/Archiver/Archiver.ts b/packages/sdk/src/sdk/services/Archiver/Archiver.ts deleted file mode 100644 index 397356d5ed9..00000000000 --- a/packages/sdk/src/sdk/services/Archiver/Archiver.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { BaseAPI, JSONApiResponse } from '../../api/generated/default' - -type GetStemsArchiveJobStatusResponse = { - id: string - state: - | 'completed' - | 'failed' - | 'active' - | 'waiting' - | 'delayed' - | 'prioritized' - progress?: number - failedReason?: string -} - -export class ArchiverService extends BaseAPI { - public async createStemsArchive({ - trackId, - userId, - includeParent - }: { - trackId: string - userId: string - includeParent?: boolean - }) { - const response = await this.request({ - method: 'POST', - path: `/stems/${trackId}`, - query: { - user_id: userId, - include_parent: !!includeParent - }, - headers: {} - }) - return new JSONApiResponse( - response - ).value() - } - - public async getStemsArchiveJobStatus({ jobId }: { jobId: string }) { - const response = await this.request({ - method: 'GET', - path: `/stems/job/${jobId}`, - headers: {} - }) - return new JSONApiResponse( - response - ).value() - } - - public async cancelStemsArchiveJob({ jobId }: { jobId: string }) { - const response = await this.request({ - method: 'DELETE', - path: `/stems/job/${jobId}`, - headers: {} - }) - return new JSONApiResponse(response).value() - } -} diff --git a/packages/sdk/src/sdk/services/Archiver/index.ts b/packages/sdk/src/sdk/services/Archiver/index.ts deleted file mode 100644 index 8e7c90221f7..00000000000 --- a/packages/sdk/src/sdk/services/Archiver/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ArchiverService } from './Archiver' diff --git a/packages/sdk/src/sdk/services/index.ts b/packages/sdk/src/sdk/services/index.ts index 6cfac7ff33a..5e08e0c932d 100644 --- a/packages/sdk/src/sdk/services/index.ts +++ b/packages/sdk/src/sdk/services/index.ts @@ -1,7 +1,6 @@ export * from './AntiAbuseOracle' export * from './Ethereum' export * from './AntiAbuseOracleSelector' -export * from './Archiver' export * from './AudiusWalletClient' export * from './EntityManager' export * from './Logger' diff --git a/packages/sdk/src/sdk/types.ts b/packages/sdk/src/sdk/types.ts index dbe8ed0db89..20c38efe614 100644 --- a/packages/sdk/src/sdk/types.ts +++ b/packages/sdk/src/sdk/types.ts @@ -4,7 +4,6 @@ import { z } from 'zod' import type { OAuthTokenStore } from './oauth/tokenStore' import { AntiAbuseOracleService } from './services/AntiAbuseOracle/types' import type { AntiAbuseOracleSelectorService } from './services/AntiAbuseOracleSelector/types' -import type { ArchiverService } from './services/Archiver' import type { AudiusWalletClient } from './services/AudiusWalletClient' import { EmailEncryptionService } from './services/Encryption' import type { EntityManagerService } from './services/EntityManager' @@ -109,7 +108,6 @@ export type ServicesContainer = { /** * Service used to create and download track archives */ - archiverService?: ArchiverService /** * Service for interacting with Audius Ethereum contracts. diff --git a/packages/web/src/app/registerNiceModals.ts b/packages/web/src/app/registerNiceModals.ts index 91ec7c5a8a8..5d3e82b2829 100644 --- a/packages/web/src/app/registerNiceModals.ts +++ b/packages/web/src/app/registerNiceModals.ts @@ -16,7 +16,6 @@ import 'components/buy-sell-modal/BuySellModal' import 'components/coinflow-onramp-modal/CoinflowOnrampModal' import 'components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal' import 'components/delete-track-confirmation-modal/DeleteTrackConfirmationModal' -import 'components/download-track-archive-modal/DownloadTrackArchiveModal' import 'components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal' import 'components/early-release-confirmation-modal/EarlyReleaseConfirmationModal' import 'components/edit-access-confirmation-modal/EditAccessConfirmationModal' diff --git a/packages/web/src/common/store/social/tracks/sagas.ts b/packages/web/src/common/store/social/tracks/sagas.ts index bb3a3545268..6a0fb011b60 100644 --- a/packages/web/src/common/store/social/tracks/sagas.ts +++ b/packages/web/src/common/store/social/tracks/sagas.ts @@ -20,7 +20,8 @@ import { formatShareText, makeKindId, removeNullable, - getFilename + getFilename, + dedupFilenames } from '@audius/common/utils' import { Id, OptionalId } from '@audius/sdk' import { @@ -642,13 +643,25 @@ function* downloadTracks({ : (nftAccessSignatureMap[parentTrackId]?.mp3 ?? null) yield* call(async () => { + // Dedup before building URLs, not after. The browser now saves each + // file straight from the network, so the name it lands under comes from + // the `Content-Disposition` header the content node builds out of this + // `filename` param — not from anything we can set at click time. A + // duplicate that isn't resolved here reaches disk as "name (1).wav". + const namedTracks = tracks.map(({ trackId, filename }) => ({ + trackId, + filename + })) + dedupFilenames(namedTracks) + const files = await Promise.all( - tracks.map(async ({ trackId, filename }) => { + namedTracks.map(async ({ trackId, filename }) => { const url = await sdk.tracks.getTrackDownloadUrl({ trackId: Id.parse(trackId), userId: OptionalId.parse(userId), userSignature: signature, userData: data, + filename, nftAccessSignature: nftAccessSignature ? JSON.stringify(nftAccessSignature) : undefined diff --git a/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx b/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx deleted file mode 100644 index 08bf3bdf299..00000000000 --- a/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useCallback, useEffect } from 'react' - -import { - useCancelStemsArchiveJob, - useDownloadTrackStems, - useGetStemsArchiveJobStatus -} from '@audius/common/api' -import { useAppContext } from '@audius/common/context' -import { ID, Name } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' -import { useDownloadTrackArchiveModal } from '@audius/common/store' -import { - Modal, - ModalContent, - ModalHeader, - Text, - Flex, - LoadingSpinner, - IconFolder, - ModalTitle, - IconReceive, - Hint, - IconError, - TextLink -} from '@audius/harmony' -import NiceModal, { useModal } from '@ebay/nice-modal-react' - -import { env } from 'services/env' - -const messages = { - title: 'Preparing Download', - zippingFiles: (count: number) => `Zipping files (${count})`, - error: 'Something went wrong. Please check your connection and try again.', - tryAgain: 'Try again.' -} - -const triggerDownload = (url: string) => { - if (document) { - const link = document.createElement('a') - link.href = url - link.click() - link.remove() - } else { - throw new Error('No document found') - } -} - -type DownloadTrackArchiveModalContentProps = { - trackId: ID - fileCount: number - isOpen: boolean - onClose: () => void - onClosed: () => void -} -const DownloadTrackArchiveModalContent = ({ - trackId, - fileCount, - isOpen, - onClose, - onClosed -}: DownloadTrackArchiveModalContentProps) => { - const { - analytics: { track, make } - } = useAppContext() - const { - mutate: downloadTrackStems, - isError: initiateDownloadFailed, - isPending: isStartingDownload, - data: { id: jobId } = {} - } = useDownloadTrackStems({ - trackId - }) - - const { mutate: cancelStemsArchiveJob } = useCancelStemsArchiveJob() - - const { - data: jobStatus, - isError: isJobStatusError, - isTimedOut: isJobTimedOut - } = useGetStemsArchiveJobStatus({ - jobId - }) - - const hasError = - !isStartingDownload && - (initiateDownloadFailed || - jobStatus?.state === 'failed' || - (!!jobId && (isJobStatusError || isJobTimedOut))) - - useEffect(() => { - if (hasError) { - track( - make({ - eventName: Name.TRACK_DOWNLOAD_FAILED_DOWNLOAD_ALL - }) - ) - } - }, [hasError, track, make]) - - useEffect(() => { - downloadTrackStems() - }, [downloadTrackStems]) - - useEffect(() => { - if (jobStatus?.state === 'completed') { - triggerDownload(`${env.ARCHIVE_ENDPOINT}/archive/stems/download/${jobId}`) - track( - make({ - eventName: Name.TRACK_DOWNLOAD_SUCCESSFUL_DOWNLOAD_ALL - }) - ) - onClose() - } - }, [jobStatus, onClose, jobId, track, make]) - - const handleClose = useCallback(() => { - if (jobId) { - cancelStemsArchiveJob({ jobId }) - } - onClose() - }, [onClose, jobId, cancelStemsArchiveJob]) - - const handleRetry = useCallback(() => { - downloadTrackStems() - }, [downloadTrackStems]) - - return ( - - - - - - - - - - {messages.zippingFiles(fileCount)} - - - {hasError ? ( - - {messages.tryAgain} - - } - > - {messages.error} - - ) : ( - - )} - - - - ) -} - -export const DownloadTrackArchiveModal = NiceModal.create(() => { - const modal = useModal() - const { - data: { trackId, fileCount } - } = useDownloadTrackArchiveModal() - - if (!trackId) { - console.error( - 'Unexpected missing trackId when rendering DownloadTrackArchiveModal' - ) - return null - } - - return ( - modal.hide()} - onClosed={() => modal.remove()} - /> - ) -}) - -NiceModal.register('DownloadTrackArchive', DownloadTrackArchiveModal) -registerNiceModalId('DownloadTrackArchive') diff --git a/packages/web/src/components/track/DownloadSection.tsx b/packages/web/src/components/track/DownloadSection.tsx index 6662c367702..1c44bc40d9a 100644 --- a/packages/web/src/components/track/DownloadSection.tsx +++ b/packages/web/src/components/track/DownloadSection.tsx @@ -16,8 +16,7 @@ import { usePremiumContentPurchaseModal, useWaitForDownloadModal, toastActions, - PurchaseableContentType, - useDownloadTrackArchiveModal + PurchaseableContentType } from '@audius/common/store' import { USDC } from '@audius/fixed-decimal' import { @@ -106,9 +105,6 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() - const { onOpen: openDownloadTrackArchiveModal } = - useDownloadTrackArchiveModal() - const { data: fileSizes } = useFileSizes( { trackIds: [trackId, ...stemTracks.map((s) => s.track_id)], @@ -172,22 +168,20 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { ] ) + // Download All hands every stem to the browser as its own download rather + // than asking the archiver to zip them server-side. `parentTrackId` is what + // makes the saga append the full track to the batch, and it only does so + // when the parent is actually downloadable — same guard the per-row path + // relies on. const handleDownloadAll = useRequiresAccountCallback( (e: MouseEvent) => { e.stopPropagation() - // Only include parent track in count if it's downloadable - const parentTrackCount = access?.download ? 1 : 0 - openDownloadTrackArchiveModal({ - trackId, - fileCount: stemTracks.length + parentTrackCount + handleDownload({ + trackIds: stemTracks.map((s) => s.track_id), + parentTrackId: trackId }) }, - [ - openDownloadTrackArchiveModal, - trackId, - stemTracks.length, - access?.download - ] + [handleDownload, stemTracks, trackId] ) const hasStems = stemTracks.length > 0 || isUploadingStems diff --git a/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx b/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx index ad126d29a9b..9bbfe7aa419 100644 --- a/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx +++ b/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx @@ -20,7 +20,6 @@ import { stemCategoryFriendlyNames } from '@audius/common/models' import { - useDownloadTrackArchiveModal, usePremiumContentPurchaseModal, useWaitForDownloadModal, PurchaseableContentType @@ -96,8 +95,6 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => { const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() - const { onOpen: openDownloadTrackArchiveModal } = - useDownloadTrackArchiveModal() const { onOpen: openWaitForDownloadModal } = useWaitForDownloadModal() // Auto-follow the contest when a signed-in user kicks off a stem @@ -149,24 +146,22 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => { { enabled: stems.length > 0 || !!track?.is_downloadable } ) - // Action: Download All archive modal (parent + stems). + // Action: Download All. Hands the parent plus every stem to the browser as + // individual downloads instead of queueing a server-side archive job. + // Passing `parentTrackId` is what pulls the full track into the batch, and + // the saga drops it when the parent isn't downloadable. const handleDownloadAll = useRequiresAccountCallback( (e: MouseEvent) => { e.stopPropagation() if (!track) return followContestIfNeeded() - openDownloadTrackArchiveModal({ - trackId, - fileCount: stemsCount + (track.is_downloadable ? 1 : 0) + openWaitForDownloadModal({ + parentTrackId: trackId, + trackIds: stems.map((s) => s.track_id), + quality: DownloadQuality.ORIGINAL }) }, - [ - followContestIfNeeded, - openDownloadTrackArchiveModal, - trackId, - stemsCount, - track - ] + [followContestIfNeeded, openWaitForDownloadModal, trackId, stems, track] ) const handleUnlockAll = useRequiresAccountCallback( diff --git a/packages/web/src/pages/track-page/components/mobile/DownloadSection.tsx b/packages/web/src/pages/track-page/components/mobile/DownloadSection.tsx index 9d70b208afd..67dfd22f67b 100644 --- a/packages/web/src/pages/track-page/components/mobile/DownloadSection.tsx +++ b/packages/web/src/pages/track-page/components/mobile/DownloadSection.tsx @@ -7,8 +7,7 @@ import { usePremiumContentPurchaseModal, useWaitForDownloadModal, toastActions, - PurchaseableContentType, - useDownloadTrackArchiveModal + PurchaseableContentType } from '@audius/common/store' import { USDC } from '@audius/fixed-decimal' import { @@ -89,9 +88,6 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() - const { onOpen: openDownloadTrackArchiveModal } = - useDownloadTrackArchiveModal() - const { onOpen: openWaitForDownloadModal } = useWaitForDownloadModal() const onToggleExpand = useCallback(() => setExpanded((val) => !val), []) @@ -148,6 +144,10 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { ] ) + // Download All hands every stem to the browser as its own download rather + // than asking the archiver to zip them server-side. `parentTrackId` is what + // makes the saga append the full track to the batch, and it only does so + // when the parent is actually downloadable. const handleDownloadAll = useRequiresAccountCallback( (e) => { e.stopPropagation() @@ -156,21 +156,18 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { dispatch(toast({ content: messages.followToDownload })) return } - // Only include parent track in count if it's downloadable - const parentTrackCount = partialTrack?.access?.download ? 1 : 0 - openDownloadTrackArchiveModal({ - trackId, - fileCount: stemTracks.length + parentTrackCount + handleDownload({ + trackIds: stemTracks.map((s) => s.track_id), + parentTrackId: trackId }) }, [ isMobile, shouldDisplayDownloadFollowGated, - openDownloadTrackArchiveModal, + handleDownload, trackId, - stemTracks.length, - dispatch, - partialTrack?.access?.download + stemTracks, + dispatch ] ) diff --git a/packages/web/src/services/audius-sdk/audiusSdk.ts b/packages/web/src/services/audius-sdk/audiusSdk.ts index 55c27b21690..c37dca3c800 100644 --- a/packages/web/src/services/audius-sdk/audiusSdk.ts +++ b/packages/web/src/services/audius-sdk/audiusSdk.ts @@ -2,8 +2,7 @@ import { AudiusSdkWithServices, createSdkWithServices, Configuration, - SolanaRelay, - ArchiverService + SolanaRelay } from '@audius/sdk' import { createWalletClient, custom, RpcRequestError } from 'viem' import { mainnet } from 'viem/chains' @@ -42,21 +41,6 @@ export const initSdk = async () => { }) ) - const archiverService = new ArchiverService( - new Configuration({ - basePath: '/archive', - middleware: [ - { - pre: async (context) => { - const endpoint = env.ARCHIVE_ENDPOINT - const url = `${endpoint}${context.url}` - return { url, init: context.init } - } - } - ] - }) - ) - // Set up a relay to identity for Ethereum RPC requests so that identity can // pay for gas fees on approved transactions. const audiusWalletClient = await getAudiusWalletClient() @@ -98,8 +82,7 @@ export const initSdk = async () => { services: { solanaRelay, audiusWalletClient, - ethWalletClient, - archiverService + ethWalletClient } }) console.debug('[audiusSdk] SDK initted.') diff --git a/packages/web/src/services/env/env.dev.ts b/packages/web/src/services/env/env.dev.ts index b8b9925965f..d0bc49444cd 100644 --- a/packages/web/src/services/env/env.dev.ts +++ b/packages/web/src/services/env/env.dev.ts @@ -7,7 +7,6 @@ export const env: Env = { API_KEY: '2dc52ec9a4c31790cab6653de0c637f680faa993', API_URL: process.env.VITE_API_URL ?? 'http://audius-api', APP_NAME: 'audius-client', - ARCHIVE_ENDPOINT: process.env.VITE_API_URL ?? 'http://audius-api', AUDIUS_NETWORK_CHAIN_ID: 1337, AUDIUS_URL: 'https://audius.co', BITSKI_CALLBACK_URL: 'https://audius.co/bitski-callback.html', diff --git a/packages/web/src/services/env/env.prod.ts b/packages/web/src/services/env/env.prod.ts index 1d7f776061d..64bb47f4bc5 100644 --- a/packages/web/src/services/env/env.prod.ts +++ b/packages/web/src/services/env/env.prod.ts @@ -7,7 +7,6 @@ export const env: Env = { API_KEY: '8acf5eb7436ea403ee536a7334faa5e9ada4b50f', API_URL: process.env.VITE_API_URL ?? 'https://api.audius.co', APP_NAME: 'audius-client', - ARCHIVE_ENDPOINT: 'https://api.audius.co', AUDIUS_NETWORK_CHAIN_ID: 31524, AUDIUS_URL: 'https://audius.co', BITSKI_CALLBACK_URL: 'https://audius.co/bitski-callback.html', diff --git a/packages/web/src/services/track-download.ts b/packages/web/src/services/track-download.ts index 60db2613be4..1d32bfd2f66 100644 --- a/packages/web/src/services/track-download.ts +++ b/packages/web/src/services/track-download.ts @@ -6,7 +6,6 @@ import { } from '@audius/common/services' import { tracksSocialActions, downloadsActions } from '@audius/common/store' import { dedupFilenames } from '@audius/common/utils' -import { downloadZip } from 'client-zip' import { track as trackEvent } from './analytics/amplitude' @@ -14,6 +13,13 @@ const { downloadFinished } = tracksSocialActions const { beginDownload, setDownloadError } = downloadsActions +// Gap between successive anchor clicks when downloading a batch. Chrome +// coalesces rapid programmatic downloads from one origin into a single +// "Download multiple files?" permission prompt, but only if they arrive as a +// recognizable burst; firing them in the same tick makes it drop all but the +// first, and spacing them out too far makes it prompt repeatedly. +const MULTI_DOWNLOAD_STAGGER_MS = 300 + function isMobileSafari() { if (!navigator) return false return ( @@ -39,87 +45,58 @@ function browserDownload({ url, filename }: DownloadFile) { } } +const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + class TrackDownload extends TrackDownloadBase { - async downloadTracks({ - files, - rootDirectoryName, - abortSignal, - dispatch - }: DownloadTrackArgs) { - if (files.length === 0) return + /** + * Hands each file to the browser as its own download rather than bundling + * them into an archive. + * + * This used to fetch every file into memory and zip it client-side (and, + * for "Download All", delegate to the server-side archiver service). Both + * were unstable for the sets that matter most — a contest track carries + * dozens of lossless stems totalling multiple GB, WAV barely compresses, so + * the zip was pure overhead on top of a fragile job queue that routinely + * stranded users on a spinner. + * + * Individual downloads have neither problem: the browser streams each file + * straight to disk with its own progress and resume behavior, nothing is + * buffered in the tab, and there is no job to stall. The tradeoff is the + * one-time "Download multiple files?" permission prompt, which is the UX + * we're deliberately opting into. + * + * Filenames come from the server. `link.download` is ignored on + * cross-origin URLs, but these URLs point at api.audius.co, which redirects + * to a content node that sets `Content-Disposition: attachment` carrying + * the `filename` query param the download saga already signs into the URL. + */ + async downloadTracks({ files, abortSignal, dispatch }: DownloadTrackArgs) { + if (files.length === 0) { + dispatch(setDownloadError(new Error('No downloadable files found'))) + return + } dispatch(beginDownload()) dedupFilenames(files) try { - 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})`) + for (const [i, file] of files.entries()) { + if (abortSignal?.aborted) { + const abortError = new Error('Download aborted') + abortError.name = 'AbortError' + throw abortError + } + browserDownload(file) + if (i < files.length - 1) { + await delay(MULTI_DOWNLOAD_STAGGER_MS) } - }) - - 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 ?? available[0].file.filename - let 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( - available.map(({ file, response }) => { - return { - name: rootDirectoryName + '/' + file.filename, - input: response - } - }) - ).blob() - url = URL.createObjectURL(blob) - } - browserDownload({ url, filename }) dispatch(downloadFinished()) - // Track download success event const eventName = - available.length === 1 + files.length === 1 ? Name.TRACK_DOWNLOAD_SUCCESSFUL_DOWNLOAD_SINGLE : Name.TRACK_DOWNLOAD_SUCCESSFUL_DOWNLOAD_ALL trackEvent(eventName, { device: 'web' })