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
72 changes: 50 additions & 22 deletions packages/common/src/api/tan-query/tracks/useDownloadTrackStems.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useState } from 'react'

import { Id } from '@audius/sdk'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
Expand All @@ -14,7 +14,13 @@ import { useTrack } from './useTrack'

// Stop polling the archive job after this long even if it never transitions
// out of `active`, so the UI can surface an error instead of spinning forever.
const STEMS_ARCHIVE_POLL_TIMEOUT_MS = 300_000 // 5 minutes
//
// Sized for the large end of real stem sets: a contest track can carry dozens
// of lossless stems totalling multiple GB, and WAV barely compresses, so the
// server-side zip legitimately runs for many minutes. The previous 5 minute
// budget expired before those archives could finish and reported a failure for
// a job that was still making progress.
const STEMS_ARCHIVE_POLL_TIMEOUT_MS = 900_000 // 15 minutes

type GetStemsArchiveJobStatusResponse = {
id: string
Expand Down Expand Up @@ -48,9 +54,21 @@ export const useDownloadTrackStems = ({ trackId }: { trackId: ID }) => {
const queryClient = useQueryClient()
const { data: currentUserId } = useCurrentUserId()

// Use existing track data to get access information
const { data: trackAccess } = useTrack(trackId, {
select: (track) => track?.access
// Whether the parent track can be bundled into the archive. Two separate
// conditions, and both matter:
// - `is_downloadable` — the artist actually offers the full track. If
// this is false there is no downloadable parent file at all and its
// download URL 404s.
// - `access.download` — the *gating* check ("this user is allowed to
// download"), which is `true` for any ungated track regardless of
// whether a downloadable file exists.
// Checking only the latter asks the archiver to include a file that isn't
// there, which is how stem archives for stem-only tracks broke.
const { data: parentDownloadability } = useTrack(trackId, {
select: (track) => ({
isDownloadable: track?.is_downloadable === true,
hasDownloadAccess: track?.access?.download === true
})
})

return useMutation({
Expand All @@ -64,8 +82,9 @@ export const useDownloadTrackStems = ({ trackId }: { trackId: ID }) => {
throw new Error('Current user ID is required')
}

// Use track access info to determine if parent track is downloadable
const includeParent = trackAccess?.download === true
const includeParent =
parentDownloadability?.isDownloadable === true &&
parentDownloadability?.hasDownloadAccess === true

return await archiver.createStemsArchive({
trackId: Id.parse(trackId),
Expand Down Expand Up @@ -115,14 +134,14 @@ export const useGetStemsArchiveJobStatus = (
) => {
const { audiusSdk } = useQueryContext()

// Track when polling for this job began so we can enforce a hard timeout even
// if the job never transitions out of `active`.
const jobStartTimeRef = useRef<number | null>(null)
useEffect(() => {
jobStartTimeRef.current = jobId ? Date.now() : null
}, [jobId])
// Hard stop for a job that never transitions out of `active`. This has to be
// real state rather than a ref: returning `false` from `refetchInterval`
// silently stops polling without re-rendering, and the job state stays
// `active` forever, so callers had no way to tell a stalled job from an
// in-progress one and would spin indefinitely.
const [isTimedOut, setIsTimedOut] = useState(false)

return useQuery({
const query = useQuery({
queryKey: getStemsArchiveJobQueryKey(jobId),
queryFn: async () => {
if (!jobId) {
Expand All @@ -135,15 +154,9 @@ export const useGetStemsArchiveJobStatus = (
}
return await archiver.getStemsArchiveJobStatus({ jobId })
},
// refetch once per second until the job is completed or failed
// refetch once per second until the job is completed, failed, or we give up
refetchInterval: (query) => {
// Hard stop: give up polling after the timeout even if the job is still
// reported as active, so we don't spin forever on a stalled job.
const jobStartTime = jobStartTimeRef.current
if (
jobStartTime !== null &&
Date.now() - jobStartTime > STEMS_ARCHIVE_POLL_TIMEOUT_MS
) {
if (isTimedOut) {
return false
}
if (!query.state.data) {
Expand All @@ -159,4 +172,19 @@ export const useGetStemsArchiveJobStatus = (
enabled: !!jobId,
...options
})

const jobState = query.data?.state
const isSettled = jobState === 'completed' || jobState === 'failed'

useEffect(() => {
setIsTimedOut(false)
if (!jobId || isSettled) return
const timer = setTimeout(
() => setIsTimedOut(true),
STEMS_ARCHIVE_POLL_TIMEOUT_MS
)
return () => clearTimeout(timer)
}, [jobId, isSettled])

return { ...query, isTimedOut }
}
24 changes: 15 additions & 9 deletions packages/common/src/api/tan-query/tracks/useUpdateTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { handleStemUpdates } from '../utils/handleStemUpdates'
import { primeTrackData } from '../utils/primeTrackData'

import { useDeleteTrack } from './useDeleteTrack'
import { getStemsQueryKey } from './useStems'
import { getTrackQueryKey } from './useTrack'

const { getCurrentUploads } = stemsUploadSelectors
Expand Down Expand Up @@ -174,15 +175,20 @@ export const useUpdateTrack = () => {
store.getState() as CommonState,
trackId
)
if (previousMetadata) {
handleStemUpdates(
metadata,
previousMetadata as any,
inProgressStemUploads,
(trackId: ID) => deleteTrack({ trackId }),
dispatch
)
}
// Server view of the track's stems. Anything not represented in the
// submitted metadata is treated as removed, so this has to come from the
// stems query rather than the cached track — the track's `_stems` field
// was never populated, which silently discarded every stem removal.
const existingStems =
queryClient.getQueryData(getStemsQueryKey(trackId)) ?? []
handleStemUpdates(
metadata,
trackId,
existingStems,
inProgressStemUploads,
(trackId: ID) => deleteTrack({ trackId }),
dispatch
)

// New-remix analytics — replaces the legacy `trackNewRemixEvent` saga
// helper. Fires when the parent_track_id changes.
Expand Down
125 changes: 125 additions & 0 deletions packages/common/src/api/tan-query/utils/handleStemUpdates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest'

import { StemCategory } from '~/models/Stems'
import { stemsUploadActions } from '~/store/stems-upload'

import { handleStemUpdates } from './handleStemUpdates'

const TRACK_ID = 100

const existingStem = (track_id: number) => ({ track_id })

/** A stem already published on the track, as the edit form re-submits it. */
const submittedExistingStem = (track_id: number) => ({
metadata: { track_id },
category: StemCategory.OTHER
})

/** A newly dropped file that hasn't been uploaded yet. */
const submittedNewStem = (name: string) => ({
file: new File([], name),
metadata: { title: name },
category: StemCategory.OTHER
})

const run = ({
stems,
existingStems,
inProgressStemUploads = []
}: {
stems?: any[]
existingStems: { track_id: number }[]
inProgressStemUploads?: any[]
}) => {
const deleteTrack = vi.fn()
const dispatch = vi.fn()
handleStemUpdates(
{ stems } as any,
TRACK_ID,
existingStems,
inProgressStemUploads,
deleteTrack,
dispatch
)
return { deleteTrack, dispatch }
}

describe('handleStemUpdates', () => {
it('deletes stems that were dropped from the submitted list', () => {
const { deleteTrack } = run({
stems: [submittedExistingStem(1), submittedExistingStem(3)],
existingStems: [existingStem(1), existingStem(2), existingStem(3)]
})

expect(deleteTrack).toHaveBeenCalledTimes(1)
expect(deleteTrack).toHaveBeenCalledWith(2)
})

it('deletes every stem when the submitted list is empty', () => {
const { deleteTrack } = run({
stems: [],
existingStems: [existingStem(1), existingStem(2)]
})

expect(deleteTrack).toHaveBeenCalledTimes(2)
expect(deleteTrack).toHaveBeenCalledWith(1)
expect(deleteTrack).toHaveBeenCalledWith(2)
})

it('does nothing when stems are absent from the submitted metadata', () => {
// Partial updates that never touch stems (e.g. toggling downloadability)
// must not be read as "the artist removed everything".
const { deleteTrack, dispatch } = run({
stems: undefined,
existingStems: [existingStem(1), existingStem(2)]
})

expect(deleteTrack).not.toHaveBeenCalled()
expect(dispatch).not.toHaveBeenCalled()
})

it('cannot detect removals when the existing stem list is unknown', () => {
// The safe failure mode: an empty server view means we skip deletions
// rather than guessing.
const { deleteTrack } = run({ stems: [], existingStems: [] })

expect(deleteTrack).not.toHaveBeenCalled()
})

it('does not delete stems whose upload is still in flight', () => {
const { deleteTrack } = run({
stems: [],
existingStems: [existingStem(1), existingStem(2)],
inProgressStemUploads: [{ metadata: { track_id: 2 } }]
})

expect(deleteTrack).toHaveBeenCalledTimes(1)
expect(deleteTrack).toHaveBeenCalledWith(1)
})

it('starts uploads for newly added files without re-uploading existing stems', () => {
const { dispatch } = run({
stems: [submittedExistingStem(1), submittedNewStem('kick.wav')],
existingStems: [existingStem(1)]
})

expect(dispatch).toHaveBeenCalledTimes(1)
const action = dispatch.mock.calls[0][0]
expect(action.type).toBe(stemsUploadActions.startStemUploads.type)
expect(action.payload.parentId).toBe(TRACK_ID)
expect(action.payload.uploads).toHaveLength(1)
expect(action.payload.uploads[0].metadata.title).toBe('kick.wav')
})

it('handles a same-name replacement as one delete plus one upload', () => {
// The artist's actual workflow: swap a published stem for a smaller file
// of the same name.
const { deleteTrack, dispatch } = run({
stems: [submittedNewStem('Pads.wav')],
existingStems: [existingStem(7)]
})

expect(deleteTrack).toHaveBeenCalledWith(7)
expect(dispatch.mock.calls[0][0].payload.uploads).toHaveLength(1)
})
})
38 changes: 25 additions & 13 deletions packages/common/src/api/tan-query/utils/handleStemUpdates.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,50 @@
import { Track } from '@audius/sdk'
import { Dispatch } from 'redux'

import { ID } from '~/models/Identifiers'
import { StemUploadWithFile } from '~/models/Stems'
import { Stem } from '~/models/Track'
import { stemsUploadActions } from '~/store/stems-upload'
import { TrackMetadataForUpload } from '~/store/upload'
import { uuid } from '~/utils/uid'

const { startStemUploads } = stemsUploadActions

type TrackWithStems = {
_stems?: Stem[]
track_id: number
} & Partial<Track>
/**
* The shape this module needs off an already-published stem. Both the
* `StemTrack`s returned by `useStems` and the lighter `Stem` records satisfy
* it.
*/
type ExistingStem = {
track_id: ID
}

/**
* Handles stem updates for a track, including new uploads and removals
* @param track The track being updated with new metadata
* @param currentTrack The existing track data
* Reconciles the stem list submitted by an edit form against the stems already
* published on the track: uploads the ones that were added, deletes the ones
* that were removed.
*
* `existingStems` must be the *server* view of the track's stems (from the
* stems query), not anything derived from the submitted metadata. Passing an
* empty list means "no removals can be detected" — which is the safe failure
* mode, and what this did unconditionally when it read the never-populated
* `_stems` field off the cached track.
*
* @param metadata The track metadata being submitted
* @param trackId The track being updated — parent for any new stem uploads
* @param existingStems The stems currently published on the track
* @param inProgressStemUploads Any stem uploads that are currently in progress
* @param deleteTrack Callback used to delete a removed stem's track
* @param dispatch Redux dispatch function
*/
export const handleStemUpdates = (
metadata: Partial<TrackMetadataForUpload>,
currentTrack: TrackWithStems,
trackId: ID,
existingStems: ExistingStem[],
inProgressStemUploads: any[] = [],
deleteTrack: (trackId: ID) => void,
dispatch: Dispatch
) => {
if (!metadata.stems) return

const existingStems = currentTrack._stems || []

// Calculate stems to upload (new stems)
const addedStems = metadata.stems.filter((stem) => {
return !existingStems.find((existingStem) => {
Expand Down Expand Up @@ -61,7 +73,7 @@ export const handleStemUpdates = (
if (addedStemsWithFiles.length > 0) {
dispatch(
startStemUploads({
parentId: currentTrack.track_id,
parentId: trackId,
uploads: addedStemsWithFiles,
batchUID: uuid()
})
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/hooks/purchaseContent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './useChallengeCooldownSchedule'
export * from './useUSDCPurchaseConfig'
export * from './usePurchaseContentErrorMessage'
export * from './usePayExtraPresets'
export * from './usePurchaseableStemCount'
export * from './utils'
export * from './types'
export * from './constants'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useStems } from '~/api/tan-query/tracks/useStems'

import { PurchaseableContentMetadata } from './types'

/**
* Number of stems included in a download-gated purchase, used to itemize what
* the buyer is paying for.
*
* Reads the stems query rather than anything on the metadata object: the
* `_stems` field this previously used was never populated anywhere in the app,
* so every purchase flow reported zero stems. Albums have no stems of their
* own and resolve to 0.
*/
export const usePurchaseableStemCount = (
metadata: PurchaseableContentMetadata | undefined
) => {
const trackId =
metadata && 'track_id' in metadata ? metadata.track_id : undefined
const { data: stems } = useStems(trackId)

const isDownloadGated =
!!metadata && 'is_download_gated' in metadata && metadata.is_download_gated

return isDownloadGated ? (stems?.length ?? 0) : 0
}
1 change: 0 additions & 1 deletion packages/common/src/models/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,6 @@ export type ComputedTrackProperties = {
_followees?: Followee[]
_marked_deleted?: boolean
_is_publishing?: boolean
_stems?: Stem[]

// Present iff remixes have been fetched for a track
_remixes?: Array<{ track_id: ID }>
Expand Down
Loading
Loading