From add34264623910e562eea51d2993fd9853a5d8c4 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 23 Oct 2026 13:29:53 -0700 Subject: [PATCH] feat(stems): download all stems as one streamed zip from the API Download All now hits GET /v1/tracks/{id}/stems/download, which streams the zip with a known length, instead of creating an archiver job and polling it. Web hands the signed URL to the browser's download manager and drops the "Zipping files" modal. Mobile downloads the URL straight from the drawer. Removes the archiver job hooks, the archiverService SDK wiring and ARCHIVE_ENDPOINT. Adds tracks.getTrackStemsDownloadUrl to the SDK. Co-Authored-By: Claude Opus 5.5 --- .changeset/stems-zip-download-url.md | 5 + .../common/src/api/tan-query/queryKeys.ts | 2 - .../tan-query/tracks/useDownloadTrackStems.ts | 175 ++---------------- packages/common/src/services/env.ts | 1 - .../DownloadTrackArchiveDrawer.tsx | 103 +++-------- 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/api/tracks/TracksApi.ts | 36 ++++ packages/web/src/app/registerNiceModals.ts | 7 - .../DownloadTrackArchiveModal.tsx | 174 ----------------- .../src/components/track/DownloadSection.tsx | 21 +-- .../web/src/hooks/useDownloadTrackStemsZip.ts | 29 +++ .../components/ContestStemsCard.tsx | 20 +- .../components/mobile/DownloadSection.tsx | 24 +-- .../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 - 18 files changed, 132 insertions(+), 511 deletions(-) create mode 100644 .changeset/stems-zip-download-url.md delete mode 100644 packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx create mode 100644 packages/web/src/hooks/useDownloadTrackStemsZip.ts diff --git a/.changeset/stems-zip-download-url.md b/.changeset/stems-zip-download-url.md new file mode 100644 index 00000000000..6345ef3f65e --- /dev/null +++ b/.changeset/stems-zip-download-url.md @@ -0,0 +1,5 @@ +--- +'@audius/sdk': minor +--- + +Add `tracks.getTrackStemsDownloadUrl` for downloading a track's stems as one zip diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index e2c83b739bd..447dde44478 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 index 6f68c64251c..fae80a29ff3 100644 --- a/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts +++ b/packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts @@ -1,69 +1,29 @@ -import { useEffect, useState } from 'react' - -import { Id } from '@audius/sdk' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Id, OptionalId } from '@audius/sdk' +import { useMutation } 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 -} - +/** + * Builds a signed url for a zip of the track's stems. The API streams the zip + * as it fetches each file, so callers hand the url to the platform's + * downloader instead of waiting on a job. + */ export const useDownloadTrackStems = ({ trackId }: { trackId: ID }) => { - const { audiusSdk } = useQueryContext() - const queryClient = useQueryClient() + const { audiusSdk, audiusBackend } = useQueryContext() const { data: currentUserId } = useCurrentUserId() - // Whether the parent track can be bundled into the archive. Two separate + // Whether the parent track can be bundled into the zip. 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. + // this is false there is no downloadable parent file at all. // - `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, @@ -74,117 +34,22 @@ export const useDownloadTrackStems = ({ trackId }: { trackId: ID }) => { 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 + const { data, signature } = await audiusBackend.signGatedContentRequest({ + sdk + }) - return await archiver.createStemsArchive({ + return await sdk.tracks.getTrackStemsDownloadUrl({ trackId: Id.parse(trackId), - userId: Id.parse(currentUserId), - includeParent + userId: OptionalId.parse(currentUserId), + userSignature: signature, + userData: data, + includeParent: + parentDownloadability?.isDownloadable === true && + parentDownloadability?.hasDownloadAccess === true }) }, - 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/mobile/src/components/download-track-archive-drawer/DownloadTrackArchiveDrawer.tsx b/packages/mobile/src/components/download-track-archive-drawer/DownloadTrackArchiveDrawer.tsx index 2805a6b9801..19cd345d785 100644 --- a/packages/mobile/src/components/download-track-archive-drawer/DownloadTrackArchiveDrawer.tsx +++ b/packages/mobile/src/components/download-track-archive-drawer/DownloadTrackArchiveDrawer.tsx @@ -1,11 +1,6 @@ import { useCallback, useEffect, useState } from 'react' -import { - useCancelStemsArchiveJob, - useDownloadTrackStems, - useGetStemsArchiveJobStatus, - useTrack -} from '@audius/common/api' +import { useDownloadTrackStems, useTrack } from '@audius/common/api' import { useAppContext } from '@audius/common/context' import type { ID } from '@audius/common/models' import type { DownloadFile } from '@audius/common/services' @@ -21,14 +16,13 @@ import { 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})`, + files: (count: number) => `${count} ${count === 1 ? 'file' : 'files'}`, error: 'Something went wrong. Please check your connection and try again.', tryAgain: 'Try again.' } @@ -98,16 +92,9 @@ const DownloadTrackArchiveDrawerContent = ({ 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 { mutateAsync: getStemsZipUrl, isError: urlFailed } = + useDownloadTrackStems({ trackId }) const { downloadFile, @@ -117,49 +104,22 @@ const DownloadTrackArchiveDrawerContent = ({ cancel: cancelDownload } = useDownloadFile() - const { mutate: cancelStemsArchiveJob } = useCancelStemsArchiveJob() - - const { - data: jobState, - isError: isJobStatusError, - isTimedOut: isJobTimedOut - } = useGetStemsArchiveJobStatus({ - jobId - }) + const hasError = urlFailed || !!downloadError - // `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(() => { - downloadTrackStems() - }, [downloadTrackStems]) + const startDownload = useCallback(async () => { + if (!trackTitle) return + resetDownload() + try { + const url = await getStemsZipUrl() + await downloadFile({ file: { url, filename: `${trackTitle}.zip` } }) + } catch (e) { + console.error('Failed to start stems download', e) + } + }, [trackTitle, getStemsZipUrl, downloadFile, resetDownload]) 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` - } - }) - onClose() - } - fetchResult() - } - }, [jobState, onClose, jobId, downloadFile, trackTitle]) + startDownload() + }, [startDownload]) // Close drawer automatically if download was successful useEffect(() => { @@ -169,18 +129,9 @@ const DownloadTrackArchiveDrawerContent = ({ }, [downloadSuccess, onClose]) const handleClose = useCallback(() => { - if (jobId) { - cancelStemsArchiveJob({ jobId }) - } cancelDownload() onClose() - }, [onClose, jobId, cancelStemsArchiveJob, cancelDownload]) - - const handleRetry = useCallback(() => { - setStep('zipping') - resetDownload() - downloadTrackStems() - }, [downloadTrackStems, resetDownload]) + }, [onClose, cancelDownload]) return ( @@ -188,25 +139,17 @@ const DownloadTrackArchiveDrawerContent = ({ - {step === 'zipping' ? ( - <> - - - {messages.zippingFiles(fileCount)} - - - ) : ( - - {`${trackTitle}.zip`} - - )} + + + {messages.files(fileCount)} + {hasError ? ( + {messages.tryAgain} } 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/api/tracks/TracksApi.ts b/packages/sdk/src/sdk/api/tracks/TracksApi.ts index c005ff56fc5..580a812905e 100644 --- a/packages/sdk/src/sdk/api/tracks/TracksApi.ts +++ b/packages/sdk/src/sdk/api/tracks/TracksApi.ts @@ -169,6 +169,42 @@ export class TracksApi extends GeneratedTracksApi { }` } + /** + * Get the url of a zip of the track's stems, optionally with the parent + * track. The API streams the zip, so the url can be handed straight to a + * browser or file downloader. + */ + async getTrackStemsDownloadUrl(params: { + trackId: string + userId?: string + userSignature?: string + userData?: string + includeParent?: boolean + }): Promise { + if (params.trackId === null || params.trackId === undefined) { + throw new RequiredError( + 'trackId', + 'Required parameter params.trackId was null or undefined when calling getTrackStemsDownloadUrl.' + ) + } + + const queryParams = new URLSearchParams() + if (params.userId) queryParams.append('user_id', params.userId) + if (params.userSignature) + queryParams.append('user_signature', params.userSignature) + if (params.userData) queryParams.append('user_data', params.userData) + if (params.includeParent) queryParams.append('include_parent', 'true') + + const path = `/tracks/{track_id}/stems/download`.replace( + `{${'track_id'}}`, + encodeURIComponent(String(params.trackId)) + ) + const queryString = queryParams.toString() + return `${this.configuration.basePath}${path}${ + queryString ? '?' + queryString : '' + }` + } + /** @hidden * Upload track files, does not write to chain */ diff --git a/packages/web/src/app/registerNiceModals.ts b/packages/web/src/app/registerNiceModals.ts index 2ebda3155c6..718d03f54e8 100644 --- a/packages/web/src/app/registerNiceModals.ts +++ b/packages/web/src/app/registerNiceModals.ts @@ -183,13 +183,6 @@ registerLazyModal('DeleteTrackConfirmation', () => default: m.DeleteTrackConfirmationModal })) ) -registerLazyModal('DownloadTrackArchive', () => - import( - 'components/download-track-archive-modal/DownloadTrackArchiveModal' - ).then((m) => ({ - default: m.DownloadTrackArchiveModal - })) -) registerLazyModal('DuplicateAddConfirmation', () => import( 'components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal' 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 8a9ef6e7507..00000000000 --- a/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { useCallback, useEffect } from 'react' - -import { - useCancelStemsArchiveJob, - useDownloadTrackStems, - useGetStemsArchiveJobStatus -} from '@audius/common/api' -import { ID } from '@audius/common/models' -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 { - 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(() => { - downloadTrackStems() - }, [downloadTrackStems]) - - useEffect(() => { - if (jobStatus?.state === 'completed') { - triggerDownload(`${env.ARCHIVE_ENDPOINT}/archive/stems/download/${jobId}`) - onClose() - } - }, [jobStatus, onClose, jobId]) - - 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()} - /> - ) -}) diff --git a/packages/web/src/components/track/DownloadSection.tsx b/packages/web/src/components/track/DownloadSection.tsx index 1de0bd1642c..62722991643 100644 --- a/packages/web/src/components/track/DownloadSection.tsx +++ b/packages/web/src/components/track/DownloadSection.tsx @@ -15,8 +15,7 @@ import { usePremiumContentPurchaseModal, useWaitForDownloadModal, toastActions, - PurchaseableContentType, - useDownloadTrackArchiveModal + PurchaseableContentType } from '@audius/common/store' import { USDC } from '@audius/fixed-decimal' import { @@ -34,6 +33,7 @@ import { useDispatch } from 'react-redux' import { useModalState } from 'common/hooks/useModalState' import { Expandable } from 'components/expandable/Expandable' +import { useDownloadTrackStemsZip } from 'hooks/useDownloadTrackStemsZip' import { useIsMobile } from 'hooks/useIsMobile' import { useRequiresAccountCallback, @@ -103,8 +103,7 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() - const { onOpen: openDownloadTrackArchiveModal } = - useDownloadTrackArchiveModal() + const downloadStemsZip = useDownloadTrackStemsZip(trackId) const { data: fileSizes } = useFileSizes( { @@ -153,19 +152,9 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { 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 - }) + downloadStemsZip() }, - [ - openDownloadTrackArchiveModal, - trackId, - stemTracks.length, - access?.download - ] + [downloadStemsZip] ) const hasStems = stemTracks.length > 0 || isUploadingStems diff --git a/packages/web/src/hooks/useDownloadTrackStemsZip.ts b/packages/web/src/hooks/useDownloadTrackStemsZip.ts new file mode 100644 index 00000000000..d1798dd26ed --- /dev/null +++ b/packages/web/src/hooks/useDownloadTrackStemsZip.ts @@ -0,0 +1,29 @@ +import { useCallback } from 'react' + +import { useDownloadTrackStems } from '@audius/common/api' +import { ID } from '@audius/common/models' + +// The response is an attachment, so the browser's download manager takes it +// and the page stays put. +const triggerDownload = (url: string) => { + const link = document.createElement('a') + link.href = url + link.click() + link.remove() +} + +/** + * Downloads a zip of the track's stems, plus the parent track when the user + * may download it. + */ +export const useDownloadTrackStemsZip = (trackId: ID) => { + const { mutateAsync: getStemsZipUrl } = useDownloadTrackStems({ trackId }) + + return useCallback(async () => { + try { + triggerDownload(await getStemsZipUrl()) + } catch (e) { + console.error('Failed to start stems download', e) + } + }, [getStemsZipUrl]) +} diff --git a/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx b/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx index ad126d29a9b..8460f99ba79 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 @@ -43,6 +42,7 @@ import { import { useLinkClickHandler } from 'react-router' import { UserLink } from 'components/link/UserLink' +import { useDownloadTrackStemsZip } from 'hooks/useDownloadTrackStemsZip' import { useRequiresAccountCallback } from 'hooks/useRequiresAccount' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' @@ -96,8 +96,7 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => { const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() - const { onOpen: openDownloadTrackArchiveModal } = - useDownloadTrackArchiveModal() + const downloadStemsZip = useDownloadTrackStemsZip(trackId) const { onOpen: openWaitForDownloadModal } = useWaitForDownloadModal() // Auto-follow the contest when a signed-in user kicks off a stem @@ -149,24 +148,15 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => { { enabled: stems.length > 0 || !!track?.is_downloadable } ) - // Action: Download All archive modal (parent + stems). + // Action: Download All as one zip (parent + stems). const handleDownloadAll = useRequiresAccountCallback( (e: MouseEvent) => { e.stopPropagation() if (!track) return followContestIfNeeded() - openDownloadTrackArchiveModal({ - trackId, - fileCount: stemsCount + (track.is_downloadable ? 1 : 0) - }) + downloadStemsZip() }, - [ - followContestIfNeeded, - openDownloadTrackArchiveModal, - trackId, - stemsCount, - track - ] + [followContestIfNeeded, downloadStemsZip, 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 abeeeb13730..b3e092c73d3 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 { @@ -25,6 +24,7 @@ import { useDispatch } from 'react-redux' import { useModalState } from 'common/hooks/useModalState' import { Expandable } from 'components/expandable/Expandable' +import { useDownloadTrackStemsZip } from 'hooks/useDownloadTrackStemsZip' import { useIsMobile } from 'hooks/useIsMobile' import { useRequiresAccountCallback, @@ -87,8 +87,7 @@ export const DownloadSection = ({ trackId }: DownloadSectionProps) => { const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() - const { onOpen: openDownloadTrackArchiveModal } = - useDownloadTrackArchiveModal() + const downloadStemsZip = useDownloadTrackStemsZip(trackId) const { onOpen: openWaitForDownloadModal } = useWaitForDownloadModal() @@ -135,22 +134,9 @@ 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 - }) + downloadStemsZip() }, - [ - isMobile, - shouldDisplayDownloadFollowGated, - openDownloadTrackArchiveModal, - trackId, - stemTracks.length, - dispatch, - partialTrack?.access?.download - ] + [isMobile, shouldDisplayDownloadFollowGated, downloadStemsZip, dispatch] ) const hasStems = stemTracks.length > 0 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',