From 07ba97a1971c319b14930f5cbcbe062e4b491190 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 18 Aug 2026 15:45:43 -0700 Subject: [PATCH] fix(client): download stems individually instead of server-side zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Download All" queued a job with the archiver service, which zipped the stems server-side while the client polled for up to 15 minutes. Jobs routinely sat in `waiting` with no worker, so users watched a spinner that never resolved — the failure Michael hit on Summer Cypher Vol 2. Hand each file to the browser as its own download instead. Chrome gates a burst of programmatic downloads behind a single "Download multiple files?" prompt, so the batch still costs one approval, and there's no job to stall, nothing buffered in the tab, and each file gets the browser's own progress and resume handling. Filenames come from the server: `/v1/tracks/:id/download` redirects to a content node that sets `Content-Disposition: attachment` from the `filename` query param, so the saga now signs the name it already computes into the URL. That means deduping has to happen before the URLs are built rather than at click time. Native has no browser to hand a batch to, so mobile's Download All goes back to fetching and zipping on-device — same saga, no queue. Removes the archiver client entirely: the SDK service, the modal/drawer, the polling hooks, and ARCHIVE_ENDPOINT. --- packages/common/src/api/index.ts | 1 - .../common/src/api/tan-query/queryKeys.ts | 2 - .../tan-query/tracks/useDownloadTrackStems.ts | 190 ------------- packages/common/src/services/env.ts | 1 - .../download-track-archive-modal/index.ts | 24 -- packages/common/src/store/ui/modals/index.ts | 1 - .../common/src/store/ui/modals/parentSlice.ts | 1 - .../common/src/store/ui/modals/reducers.ts | 2 - packages/common/src/store/ui/modals/types.ts | 3 - packages/common/src/utils/fileUtil.ts | 6 +- packages/mobile/src/app/Drawers.tsx | 2 - .../DownloadTrackArchiveDrawer.tsx | 269 ------------------ .../download-track-archive-drawer/index.ts | 1 - .../screens/track-screen/DownloadSection.tsx | 25 +- packages/mobile/src/services/env/env.dev.ts | 1 - packages/mobile/src/services/env/env.prod.ts | 1 - .../mobile/src/services/sdk/audius-sdk.ts | 21 +- packages/sdk/src/sdk/createSdkWithServices.ts | 11 - .../sdk/src/sdk/services/Archiver/Archiver.ts | 59 ---- .../sdk/src/sdk/services/Archiver/index.ts | 1 - packages/sdk/src/sdk/services/index.ts | 1 - packages/sdk/src/sdk/types.ts | 2 - packages/web/src/app/registerNiceModals.ts | 1 - .../src/common/store/social/tracks/sagas.ts | 17 +- .../DownloadTrackArchiveModal.tsx | 197 ------------- .../src/components/track/DownloadSection.tsx | 26 +- .../components/ContestStemsCard.tsx | 23 +- .../components/mobile/DownloadSection.tsx | 25 +- .../web/src/services/audius-sdk/audiusSdk.ts | 21 +- packages/web/src/services/env/env.dev.ts | 1 - packages/web/src/services/env/env.prod.ts | 1 - packages/web/src/services/track-download.ts | 117 +++----- 32 files changed, 112 insertions(+), 942 deletions(-) delete mode 100644 packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts delete mode 100644 packages/common/src/store/ui/modals/download-track-archive-modal/index.ts delete mode 100644 packages/mobile/src/components/download-track-archive-drawer/DownloadTrackArchiveDrawer.tsx delete mode 100644 packages/mobile/src/components/download-track-archive-drawer/index.ts delete mode 100644 packages/sdk/src/sdk/services/Archiver/Archiver.ts delete mode 100644 packages/sdk/src/sdk/services/Archiver/index.ts delete mode 100644 packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx 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' })