Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stems-zip-download-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@audius/sdk': minor
---

Add `tracks.getTrackStemsDownloadUrl` for downloading a track's stems as one zip
2 changes: 0 additions & 2 deletions packages/common/src/api/tan-query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ export const QUERY_KEYS = {
comment: 'comment',
commentReplies: 'commentReplies',
computedProps: 'computedProps',
downloadTrackStems: 'downloadTrackStems',
stemsArchiveJob: 'stemsArchiveJob',
exploreContent: 'exploreContent',
trackCommentNotificationSetting: 'trackCommentNotificationSetting',
trackCommentCount: 'trackCommentCount',
Expand Down
175 changes: 20 additions & 155 deletions packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts
Original file line number Diff line number Diff line change
@@ -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<GetStemsArchiveJobStatusResponse>
}

export const getDownloadTrackStemsQueryKey = (trackId: ID) => {
return [
QUERY_KEYS.downloadTrackStems,
trackId
] as unknown as QueryKey<GetStemsArchiveJobStatusResponse>
}

/**
* 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,
Expand All @@ -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 }
}
1 change: 0 additions & 1 deletion packages/common/src/services/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.'
}
Expand Down Expand Up @@ -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,
Expand All @@ -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(() => {
Expand All @@ -169,44 +129,27 @@ 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 (
<Drawer isOpen={isOpen} onClose={handleClose} onClosed={onClosed}>
<Flex ph='l' pv='xl' gap='xl'>
<DrawerHeader icon={IconReceive} title={messages.title} />
<Flex justifyContent='center' alignItems='center' gap='xl'>
<Flex row alignItems='center' gap='l'>
{step === 'zipping' ? (
<>
<IconFolder color='default' size='l' />
<Text variant='body' size='l' strength='strong'>
{messages.zippingFiles(fileCount)}
</Text>
</>
) : (
<Text variant='body' size='l' strength='strong'>
{`${trackTitle}.zip`}
</Text>
)}
<IconFolder color='default' size='l' />
<Text variant='body' size='l' strength='strong'>
{messages.files(fileCount)}
</Text>
</Flex>
{hasError ? (
<Hint
icon={IconError}
border='strong'
actions={
<TextLink variant='visible' onPress={handleRetry}>
<TextLink variant='visible' onPress={startDownload}>
{messages.tryAgain}
</TextLink>
}
Expand Down
Loading
Loading