diff --git a/packages/common/src/adapters/track.ts b/packages/common/src/adapters/track.ts index af7ce63d24a..ea2b58a84b5 100644 --- a/packages/common/src/adapters/track.ts +++ b/packages/common/src/adapters/track.ts @@ -4,7 +4,6 @@ import { type Genre, type Mood, type NativeFile, - type TrackFilesMetadata, HashId, Id, OptionalHashId, @@ -235,9 +234,7 @@ export const stemTrackMetadataFromSDK = ( } } -export const trackMetadataForUploadToSdk = ( - input: TrackMetadataForUpload -): TrackFilesMetadata => ({ +export const trackMetadataForUploadToSdk = (input: TrackMetadataForUpload) => ({ ...camelcaseKeys( pick(input, [ 'license', diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 5bd1ca26b65..c55f1f4fbab 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -184,6 +184,7 @@ export * from './tan-query/authorized-apps/useRemoveAuthorizedApp' export * from './tan-query/coins' // Uploads +export * from './tan-query/upload/useUpload' export * from './tan-query/upload/useUploadFiles' export * from './tan-query/upload/useUploadStatus' export * from './tan-query/upload/usePublishTracks' diff --git a/packages/common/src/api/tan-query/tracks/useUpdateTrack.ts b/packages/common/src/api/tan-query/tracks/useUpdateTrack.ts index b034c0fbf08..3cbeab1c703 100644 --- a/packages/common/src/api/tan-query/tracks/useUpdateTrack.ts +++ b/packages/common/src/api/tan-query/tracks/useUpdateTrack.ts @@ -1,4 +1,4 @@ -import { Id } from '@audius/sdk' +import { Id, type ProgressHandler } from '@audius/sdk' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useDispatch, useStore } from 'react-redux' @@ -29,7 +29,9 @@ export type UpdateTrackParams = { trackId: ID userId: ID metadata: Partial + audioFile?: File coverArtFile?: File + onProgress?: ProgressHandler } export const useUpdateTrack = () => { @@ -44,7 +46,9 @@ export const useUpdateTrack = () => { trackId, userId, metadata, - coverArtFile + audioFile, + coverArtFile, + onProgress }: UpdateTrackParams) => { const sdk = await audiusSdk() @@ -56,12 +60,14 @@ export const useUpdateTrack = () => { ) const response = await sdk.tracks.updateTrack({ - coverArtFile: coverArtFile + audioFile: audioFile ? fileToSdk(audioFile, 'audio') : undefined, + imageFile: coverArtFile ? fileToSdk(coverArtFile, 'cover_art') : undefined, trackId: Id.parse(trackId), userId: Id.parse(userId), - metadata: sdkMetadata + metadata: sdkMetadata, + onProgress }) // TODO: migrate stem uploads to use tan-query @@ -104,6 +110,11 @@ export const useUpdateTrack = () => { // Return context with the previous track and metadata return { previousTrack } }, + onSuccess: (_, params) => { + queryClient.invalidateQueries({ + queryKey: getTrackQueryKey(params.trackId) + }) + }, onError: ( error, { trackId, userId, metadata }, diff --git a/packages/common/src/api/tan-query/upload/usePublishCollection.ts b/packages/common/src/api/tan-query/upload/usePublishCollection.ts index c56a6051170..7489d48d594 100644 --- a/packages/common/src/api/tan-query/upload/usePublishCollection.ts +++ b/packages/common/src/api/tan-query/upload/usePublishCollection.ts @@ -36,10 +36,10 @@ import { type PublishCollectionContext = Pick< QueryContextType, - 'audiusSdk' | 'analytics' | 'dispatch' + 'audiusSdk' | 'analytics' | 'dispatch' | 'reportToSentry' > & { - userId?: number - wallet?: string + userId: number + wallet: string } type PublishCollectionParams = { @@ -48,19 +48,20 @@ type PublishCollectionParams = { clientId: string metadata: TrackMetadataForUpload audioUploadResponse: UploadResponse - artUploadResponse: UploadResponse + imageUploadResponse: UploadResponse }[] } const getPublishCollectionOptions = (context: PublishCollectionContext) => mutationOptions({ mutationFn: async (params: PublishCollectionParams) => { - const sdk = await context.audiusSdk() - if (!context.userId || !context.wallet) { + const { audiusSdk, userId, wallet } = context + const sdk = await audiusSdk() + if (!userId || !wallet) { throw new Error('User ID and wallet are required to publish collection') } const userBank = await sdk.services.claimableTokensClient.deriveUserBank({ - ethWallet: context.wallet, + ethWallet: wallet, mint: 'USDC' }) @@ -108,20 +109,20 @@ const getPublishCollectionOptions = (context: PublishCollectionContext) => : undefined if (params.collectionMetadata.is_album) { return await sdk.albums.createAlbum({ - userId: Id.parse(context.userId), + userId: Id.parse(userId), coverArtFile, metadata: albumMetadataForCreateWithSDK(params.collectionMetadata), trackIds: publishedTracks - .filter((t) => !t.error) + .filter((t) => t.trackId && !t.error) .map((t) => t.trackId!) }) } else { return await sdk.playlists.createPlaylist({ - userId: Id.parse(context.userId), + userId: Id.parse(userId), coverArtFile, metadata: playlistMetadataForCreateWithSDK(params.collectionMetadata), trackIds: publishedTracks - .filter((t) => !t.error) + .filter((t) => t.trackId && !t.error) .map((t) => t.trackId!) }) } @@ -131,7 +132,7 @@ const getPublishCollectionOptions = (context: PublishCollectionContext) => export const usePublishCollection = ( options?: Partial> ) => { - const { audiusSdk, analytics } = useQueryContext() + const { audiusSdk, analytics, reportToSentry } = useQueryContext() const queryClient = useQueryClient() const dispatch = useDispatch() const { data: account = null } = useCurrentAccount() @@ -143,10 +144,11 @@ export const usePublishCollection = ( ...options, ...getPublishCollectionOptions({ audiusSdk, - userId, - wallet, + userId: userId!, + wallet: wallet!, dispatch, - analytics + analytics, + reportToSentry }), onSuccess: async (playlist) => { diff --git a/packages/common/src/api/tan-query/upload/usePublishStems.ts b/packages/common/src/api/tan-query/upload/usePublishStems.ts new file mode 100644 index 00000000000..ba1d3b9a664 --- /dev/null +++ b/packages/common/src/api/tan-query/upload/usePublishStems.ts @@ -0,0 +1,118 @@ +import { HashId, Id, type UploadResponse } from '@audius/sdk' +import { mutationOptions, useMutation } from '@tanstack/react-query' + +import { trackMetadataForUploadToSdk } from '~/adapters' +import { StemCategory, Name } from '~/models' +import { ProgressStatus, uploadActions } from '~/store' +import type { TrackMetadataForUpload } from '~/store' + +import { useCurrentUserId } from '../users/account/useCurrentUserId' +import { useQueryContext, type QueryContextType } from '../utils' + +const { updateProgress } = uploadActions + +type PublishStemsContext = Pick< + QueryContextType, + 'audiusSdk' | 'analytics' | 'dispatch' | 'reportToSentry' +> & { + userId: number +} + +type PublishStemsParams = { + clientId: string + parentTrackId: number + metadata: TrackMetadataForUpload + imageUploadResponse: UploadResponse + stemsUploadResponses: UploadResponse[] +} + +export const publishStems = async ( + context: PublishStemsContext, + params: PublishStemsParams +) => { + const { + userId, + audiusSdk, + dispatch, + analytics: { make, track } + } = context + + if (!userId) { + throw new Error('User ID is required to publish stems') + } + + const sdk = await audiusSdk() + return await Promise.all( + (params.metadata.stems ?? []).map(async (stem, index) => { + try { + const stemUploadResponse = params.stemsUploadResponses?.[index] + if (!stemUploadResponse) { + throw new Error(`No upload response found for stem ${index}`) + } + const metadata = { + ...stem.metadata, + genre: params.metadata.genre, + is_downloadable: true, + stem_of: { + category: stem.category ?? StemCategory.OTHER, + parent_track_id: params.parentTrackId + } + } + const stemRes = await sdk.tracks.publishTrack({ + userId: Id.parse(userId), + metadata: trackMetadataForUploadToSdk(metadata), + audioUploadResponse: stemUploadResponse, + imageUploadResponse: params.imageUploadResponse + }) + dispatch( + updateProgress({ + clientId: params.clientId, + stemIndex: index, + key: 'audio', + progress: { status: ProgressStatus.COMPLETE } + }) + ) + track( + make({ + eventName: Name.STEM_COMPLETE_UPLOAD, + id: HashId.parse(stemRes.trackId), + parent_track_id: params.parentTrackId, + category: stem.category ?? StemCategory.OTHER + }) + ) + return { trackId: stemRes.trackId, error: null } + } catch (e) { + dispatch( + updateProgress({ + clientId: params.clientId, + stemIndex: index, + key: 'audio', + progress: { status: ProgressStatus.ERROR } + }) + ) + console.error('Error publishing stem:', e) + return { trackId: null, error: e as Error } + } + }) + ) +} + +const getPublishStemsOptions = (context: PublishStemsContext) => + mutationOptions({ + mutationFn: async (params: PublishStemsParams) => + publishStems(context, params) + }) + +export const usePublishStems = ( + options?: Partial> & { + kind?: 'tracks' | 'album' | 'playlist' + } +) => { + const context = useQueryContext() + const { data: userId } = useCurrentUserId() + + return useMutation({ + ...options, + ...getPublishStemsOptions({ ...context, userId: userId! }) + }) +} diff --git a/packages/common/src/api/tan-query/upload/usePublishTracks.ts b/packages/common/src/api/tan-query/upload/usePublishTracks.ts index 71d8661c049..cb4a54bfabd 100644 --- a/packages/common/src/api/tan-query/upload/usePublishTracks.ts +++ b/packages/common/src/api/tan-query/upload/usePublishTracks.ts @@ -5,14 +5,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useDispatch } from 'react-redux' import { trackMetadataForUploadToSdk } from '~/adapters' import { isContentUSDCPurchaseGated, - StemCategory, type USDCPurchaseConditions, - Name + Name, + Feature } from '~/models' import { ProgressStatus, uploadActions } from '~/store' import type { TrackMetadataForUpload } from '~/store' @@ -23,15 +22,16 @@ import { useCurrentAccountUser } from '../users/account/accountSelectors' import { useCurrentAccount } from '../users/account/useCurrentAccount' import { getUserQueryKey } from '../users/useUser' import { useQueryContext, type QueryContextType } from '../utils' +import { publishStems } from './usePublishStems' const { updateProgress } = uploadActions type PublishTracksContext = Pick< QueryContextType, - 'audiusSdk' | 'analytics' | 'dispatch' + 'audiusSdk' | 'analytics' | 'dispatch' | 'reportToSentry' > & { - userId?: number - wallet?: string + userId: number + wallet: string kind?: 'tracks' | 'album' | 'playlist' } @@ -39,7 +39,7 @@ type PublishTracksParams = { clientId: string metadata: TrackMetadataForUpload audioUploadResponse: UploadResponse - artUploadResponse: UploadResponse + imageUploadResponse: UploadResponse stemsUploadResponses?: UploadResponse[] }[] @@ -47,35 +47,45 @@ export const publishTracks = async ( context: PublishTracksContext, params: PublishTracksParams ) => { + const { + userId, + wallet, + kind, + audiusSdk, + dispatch, + reportToSentry, + analytics: { make, track } + } = context + if (!context.userId || !context.wallet) { throw new Error('User ID and wallet are required to publish tracks') } - const { userId, wallet, dispatch } = context - const sdk = await context.audiusSdk() + + const sdk = await audiusSdk() const userBank = await sdk.services.claimableTokensClient.deriveUserBank({ ethWallet: wallet, mint: 'USDC' }) return await Promise.all( params.map(async (param) => { - try { - const snakeMetadata = addPremiumMetadata( - userBank.toString(), - param.metadata - ) + const snakeMetadata = addPremiumMetadata( + userBank.toString(), + param.metadata + ) - const trackId = await sdk.tracks.generateTrackId() - const camelMetadata = trackMetadataForUploadToSdk({ - ...snakeMetadata, - track_id: trackId - }) + const trackId = await sdk.tracks.generateTrackId() + const camelMetadata = trackMetadataForUploadToSdk({ + ...snakeMetadata, + track_id: trackId + }) - const publishParentTrack = async () => { + const publishParentTrack = async () => { + try { const res = await sdk.tracks.publishTrack({ userId: Id.parse(userId), metadata: camelMetadata, audioUploadResponse: param.audioUploadResponse, - artUploadResponse: param.artUploadResponse + imageUploadResponse: param.imageUploadResponse }) dispatch( updateProgress({ @@ -88,90 +98,56 @@ export const publishTracks = async ( // Track success analytics for this individual track const analyticsKind = - (context.kind ?? 'tracks') === 'tracks' + (kind ?? 'tracks') === 'tracks' ? params.length > 1 ? 'multi_track' : 'single_track' - : context.kind === 'album' + : kind === 'album' ? 'album' : 'playlist' - context.analytics?.track( - context.analytics.make({ + track( + make({ eventName: Name.TRACK_UPLOAD_SUCCESS, endpoint: '', kind: analyticsKind }) ) - return res + return { result: res, error: null } + } catch (e) { + dispatch( + updateProgress({ + clientId: param.clientId, + stemIndex: null, + key: 'audio', + progress: { status: ProgressStatus.ERROR } + }) + ) + reportToSentry({ + error: e as Error, + name: 'Upload: Track Publish', + feature: Feature.Upload + }) + console.error('Error publishing track:', e) + return { result: null, error: e as Error } } + } - const results = await Promise.all([ - publishParentTrack(), - ...(param.metadata.stems ?? []).map(async (stem, index) => { - try { - const stemUploadResponse = param.stemsUploadResponses?.[index] - if (!stemUploadResponse) { - throw new Error(`No upload response found for stem ${index}`) - } - const metadata = { - ...snakeMetadata, - ...stem.metadata, - is_downloadable: true, - stem_of: { - category: stem.category ?? StemCategory.OTHER, - parent_track_id: trackId - } - } - const stemRes = await sdk.tracks.publishTrack({ - userId: Id.parse(userId), - metadata: trackMetadataForUploadToSdk(metadata), - audioUploadResponse: stemUploadResponse, - artUploadResponse: param.artUploadResponse - }) - dispatch( - updateProgress({ - clientId: param.clientId, - stemIndex: index, - key: 'audio', - progress: { status: ProgressStatus.COMPLETE } - }) - ) - context.analytics?.track( - context.analytics.make({ - eventName: Name.STEM_COMPLETE_UPLOAD, - id: HashId.parse(stemRes.trackId), - parent_track_id: trackId, - category: stem.category ?? StemCategory.OTHER - }) - ) - return stemRes - } catch (e) { - dispatch( - updateProgress({ - clientId: param.clientId, - stemIndex: index, - key: 'audio', - progress: { status: ProgressStatus.ERROR } - }) - ) - console.error('Error publishing stem:', e) - throw e - } - }) - ]) + const [trackResult, stemsResults] = await Promise.all([ + publishParentTrack(), + publishStems(context, { + clientId: param.clientId, + metadata: param.metadata, + imageUploadResponse: param.imageUploadResponse, + stemsUploadResponses: param.stemsUploadResponses ?? [], + parentTrackId: trackId + }) + ]) - return { clientId: param.clientId, trackId: results[0].trackId } - } catch (e) { - dispatch( - updateProgress({ - clientId: param.clientId, - stemIndex: null, - key: 'audio', - progress: { status: ProgressStatus.ERROR } - }) - ) - console.error('Error publishing track:', e) - return { clientId: param.clientId, error: e } + return { + clientId: param.clientId, + trackId: trackResult.result?.trackId ?? null, + stems: stemsResults, + error: trackResult.error } }) ) @@ -188,8 +164,7 @@ export const usePublishTracks = ( kind?: 'tracks' | 'album' | 'playlist' } ) => { - const { audiusSdk, analytics } = useQueryContext() - const dispatch = useDispatch() + const queryContext = useQueryContext() const queryClient = useQueryClient() const { data: account } = useCurrentAccount() const { data: accountUser } = useCurrentAccountUser() @@ -200,24 +175,24 @@ export const usePublishTracks = ( return useMutation({ ...options, ...getPublishTracksOptions({ - audiusSdk, - userId, - wallet, - dispatch, - analytics, + ...queryContext, + userId: userId!, + wallet: wallet!, kind }), onSuccess: async (data) => { - const sdk = await audiusSdk() + const sdk = await queryContext.audiusSdk() const batchGetTracks = getTracksBatcher({ sdk, currentUserId: userId, queryClient, - dispatch + dispatch: queryContext.dispatch }) // Prefetch the published tracks into the cache await Promise.all( - data.map((res) => batchGetTracks.fetch(HashId.parse(res.trackId))) + data + .filter((res) => !res.error && res.trackId) + .map((res) => batchGetTracks.fetch(HashId.parse(res.trackId!))) ) // Invalidate the user's data to update track count diff --git a/packages/common/src/api/tan-query/upload/useUpload.ts b/packages/common/src/api/tan-query/upload/useUpload.ts new file mode 100644 index 00000000000..281b588ce80 --- /dev/null +++ b/packages/common/src/api/tan-query/upload/useUpload.ts @@ -0,0 +1,540 @@ +import { useRef, useCallback } from 'react' + +import { AudiusSdk, HashId } from '@audius/sdk' +import { useDispatch } from 'react-redux' + +import { fileToSdk } from '~/adapters' +import { + Name, + type StemUploadWithFile, + isContentFollowGated, + Feature +} from '~/models' +import { + type TrackForUpload, + uploadActions, + ProgressStatus, + type UploadFormState, + type CollectionFormState, + type TrackFormState, + UploadType +} from '~/store' + +import { type QueryContextType, useQueryContext } from '../utils' + +import { usePublishCollection } from './usePublishCollection' +import { usePublishTracks } from './usePublishTracks' +import { useUploadFiles } from './useUploadFiles' + +const { + updateProgress, + uploadTracksRequested, + uploadTracksFailed, + uploadTracksSucceeded +} = uploadActions + +const getStemUploadHandles = async ( + context: Pick, + tracks: TrackForUpload[] +) => { + const sdk = await context.audiusSdk() + return tracks.flatMap( + (t) => + t.metadata.stems?.map((stemFile, index) => { + const file = (stemFile as StemUploadWithFile).file + const uploadHandle = sdk.tracks.uploadTrackFiles({ + audioFile: fileToSdk(file, 'audio'), + onProgress: (key, { loaded, total, transcode }) => { + context.dispatch( + updateProgress({ + clientId: t.clientId, + stemIndex: index, + key, + progress: { + status: + transcode === undefined + ? ProgressStatus.UPLOADING + : ProgressStatus.PROCESSING, + loaded, + total, + transcode + } + }) + ) + } + }) + return { + clientId: t.clientId, + ...uploadHandle + } + }) ?? [] + ) +} + +const getTrackArtworkUploadHandles = async ( + context: Pick, + tracks: TrackForUpload[] +) => { + const sdk = await context.audiusSdk() + return tracks + .filter( + (t) => + t.metadata?.artwork && + 'file' in t.metadata.artwork && + t.metadata.artwork.file + ) + .map((t) => { + if ( + !t.metadata.artwork || + !('file' in t.metadata.artwork) || + !t.metadata.artwork.file + ) { + throw new Error('Artwork file missing') + } + const file = fileToSdk(t.metadata.artwork.file, 'artwork') + const uploadHandle = sdk.tracks.uploadTrackFiles({ + imageFile: file, + onProgress: (key, { loaded, total }) => { + context.dispatch( + uploadActions.updateProgress({ + clientId: t.clientId, + key, + stemIndex: null, + progress: { + status: + loaded && total && loaded >= total + ? ProgressStatus.COMPLETE + : ProgressStatus.UPLOADING, + loaded, + total, + transcode: 0 + } + }) + ) + } + }) + return { + clientId: t.clientId, + ...uploadHandle + } + }) +} + +const getTrackUploadHandles = async ( + context: Pick, + tracks: TrackForUpload[] +) => { + const sdk = await context.audiusSdk() + return tracks.map((t) => { + const handle = sdk.tracks.uploadTrackFiles({ + audioFile: fileToSdk(t.file, 'audio'), + onProgress: (key, { loaded, total, transcode }) => { + context.dispatch( + uploadActions.updateProgress({ + clientId: t.clientId, + key, + stemIndex: null, + progress: { + status: + transcode === undefined + ? ProgressStatus.UPLOADING + : ProgressStatus.PROCESSING, + loaded, + total, + transcode + } + }) + ) + } + }) + return { + clientId: t.clientId, + ...handle + } + }) +} + +export const useUpload = () => { + const dispatch = useDispatch() + const { + audiusSdk, + analytics: { make, track }, + reportToSentry + } = useQueryContext() + + const { mutateAsync: uploadFiles } = useUploadFiles() + const { mutateAsync: publishTracksAsync } = usePublishTracks() + const { mutateAsync: publishCollectionAsync } = usePublishCollection() + + // Holds the upload promise so that uploading tracks can start immediately + // and then be awaited on the finish step. + const trackUploadPromise = useRef>( + Promise.resolve([]) + ) + + // Tracks individual file uploads so they can be replaced if needed + const fileUploads = useRef< + Map[number]['file']> + >(new Map()) + + // Tracks individual file upload handles so they can be aborted if needed + const uploadHandles = useRef< + Map> + >(new Map()) + + const uploadTrackFiles = useCallback( + async (tracks: TrackForUpload[]) => { + // Track analytics for each track being uploaded + tracks.forEach((t) => { + fileUploads.current.set(t.clientId, t.file) + track( + make({ + eventName: Name.TRACK_UPLOAD_TRACK_UPLOADING, + artworkSource: + t.metadata.artwork && 'source' in t.metadata.artwork + ? (t.metadata.artwork.source as 'unsplash' | 'original') + : 'original', + trackId: t.metadata.track_id!, + genre: t.metadata.genre, + mood: t.metadata.mood ?? undefined, + size: t.file.size ?? -1, + fileType: t.file.type ?? '', + name: t.file.name ?? '', + downloadable: isContentFollowGated(t.metadata.download_conditions) + ? 'follow' + : t.metadata.is_downloadable + ? 'yes' + : 'no' + }) + ) + }) + + const handles = await getTrackUploadHandles( + { audiusSdk, dispatch }, + tracks + ) + handles.forEach((handle, i) => { + uploadHandles.current.set(tracks[i]!.clientId, handle) + }) + return await uploadFiles({ + files: handles + }) + }, + [audiusSdk, dispatch, make, track, uploadFiles] + ) + + /** + * Replaces track files that have been changed in the edit form + * by aborting their previous upload and re-uploading the new file + */ + const replaceTrackFiles = useCallback( + (tracks: TrackForUpload[]) => { + // Check if any track files were replaced (same clientId, different File) + const tracksWithReplacedFiles = + tracks?.filter((track) => { + const existingFile = fileUploads.current.get(track.clientId) + return existingFile && existingFile !== track.file + }) ?? [] + + // Abort and remove upload handles for removed or replaced files + for (const key of uploadHandles.current.keys()) { + const isRemoved = !tracks.find((t) => t.clientId === key) + const isReplaced = !!tracksWithReplacedFiles.find( + (t) => t.clientId === key + ) + if (isRemoved || isReplaced) { + uploadHandles.current.get(key)?.abort() + uploadHandles.current.delete(key) + } + } + + // Keep the existing uploads and add the new uploads for replaced files + if (tracksWithReplacedFiles.length > 0) { + trackUploadPromise.current = Promise.all([ + uploadTrackFiles(tracksWithReplacedFiles), + trackUploadPromise.current + ]).then(([newUploads, oldUploads]) => [ + ...newUploads, + ...oldUploads.filter((oldUpload) => { + return !newUploads.find((nu) => nu.clientId === oldUpload.clientId) + }) + ]) + } + }, + [uploadTrackFiles] + ) + + const uploadTrackArtworks = useCallback( + async (tracks: TrackForUpload[]) => { + return await uploadFiles({ + files: await getTrackArtworkUploadHandles( + { audiusSdk, dispatch }, + tracks + ) + }) + }, + [audiusSdk, dispatch, uploadFiles] + ) + + const uploadCollectionArtwork = useCallback( + async (formState: CollectionFormState) => { + if ( + !formState.metadata || + !formState.metadata.artwork || + !('file' in formState.metadata.artwork) || + !formState.metadata.artwork.file + ) { + return + } + const sdk = await audiusSdk() + const uploadHandle = sdk.tracks.uploadTrackFiles({ + imageFile: fileToSdk(formState.metadata.artwork.file, 'artwork'), + onProgress: (key, { loaded, total }) => { + dispatch( + uploadActions.updateProgress({ + clientId: 'collection-artwork', + key, + stemIndex: null, + progress: { + status: + loaded && total && loaded >= total + ? ProgressStatus.COMPLETE + : ProgressStatus.UPLOADING, + loaded, + total, + transcode: 0 + } + }) + ) + } + }) + return await uploadFiles({ + files: [ + { + clientId: 'collection-artwork', + ...uploadHandle + } + ] + }) + }, + [audiusSdk, dispatch, uploadFiles] + ) + + const uploadStemFiles = useCallback( + async (tracks: TrackForUpload[]) => { + return await uploadFiles({ + files: await getStemUploadHandles({ audiusSdk, dispatch }, tracks) + }) + }, + [audiusSdk, dispatch, uploadFiles] + ) + + const startUpload = useCallback( + (formState: CollectionFormState | TrackFormState) => { + trackUploadPromise.current = uploadTrackFiles(formState.tracks ?? []) + }, + [uploadTrackFiles] + ) + + const finishUpload = useCallback( + async (formState: CollectionFormState | TrackFormState) => { + const kind = (() => { + switch (formState.uploadType) { + case UploadType.ALBUM: + return 'album' + case UploadType.PLAYLIST: + return 'playlist' + case UploadType.INDIVIDUAL_TRACK: + return 'single_track' + default: + return 'multi_track' + } + })() + + const tracks = formState.tracks ?? [] + const uploadType = formState.uploadType + + // Track start of upload + track( + make({ + eventName: Name.TRACK_UPLOAD_START_UPLOADING, + count: formState.tracks?.length ?? 0, + kind + }) + ) + + dispatch(uploadTracksRequested(formState)) + + // Replace tracks as necessary + replaceTrackFiles(tracks) + + let stemUploads: Awaited> = [] + let trackUploads: Awaited> = [] + + // Wait for stems and tracks to upload before publishing + ;[stemUploads, trackUploads] = await Promise.all([ + uploadStemFiles(tracks), + trackUploadPromise.current + ]) + + if ( + uploadType === UploadType.INDIVIDUAL_TRACKS || + uploadType === UploadType.INDIVIDUAL_TRACK + ) { + try { + const artworks = await uploadTrackArtworks(tracks) + const imageUploadMap = artworks.reduce( + (acc, art) => { + acc[art.clientId] = art + return acc + }, + {} as Record + ) + const audioUploadMap = trackUploads.reduce( + (acc, track) => { + acc[track.clientId] = track + return acc + }, + {} as Record + ) + + const publishRes = await publishTracksAsync( + tracks + .filter( + (t) => + audioUploadMap[t.clientId]?.audioUploadResponse && + imageUploadMap[t.clientId]?.imageUploadResponse + ) + .map((t) => ({ + clientId: t.clientId, + metadata: t.metadata, + audioUploadResponse: + audioUploadMap[t.clientId]!.audioUploadResponse!, + imageUploadResponse: + imageUploadMap[t.clientId]!.imageUploadResponse!, + stemsUploadResponses: stemUploads + .filter( + (su) => su.clientId === t.clientId && su.audioUploadResponse + ) + .map((su) => su.audioUploadResponse!) + })) + ) + + const failedTracks = publishRes.filter((res) => res.error) + if (publishRes.length !== tracks.length || failedTracks.length > 0) { + throw new Error('Some tracks failed to publish') + } + + // Track complete upload analytics + track( + make({ + eventName: Name.TRACK_UPLOAD_COMPLETE_UPLOAD, + count: tracks.length, + kind + }) + ) + + if (uploadType === UploadType.INDIVIDUAL_TRACK) { + dispatch( + uploadTracksSucceeded({ + id: HashId.parse(publishRes[0]!.trackId) + }) + ) + } else if (uploadType === UploadType.INDIVIDUAL_TRACKS) { + dispatch(uploadTracksSucceeded({ id: null })) + } + } catch (err) { + console.error('Error publishing tracks:', err) + track( + make({ + eventName: Name.TRACK_UPLOAD_FAILURE, + kind + }) + ) + dispatch(uploadTracksFailed()) + } + } else if ( + uploadType === UploadType.ALBUM || + uploadType === UploadType.PLAYLIST + ) { + try { + const artwork = await uploadCollectionArtwork( + formState as CollectionFormState + ) + const publishRes = await publishCollectionAsync({ + collectionMetadata: formState.metadata, + tracks: tracks.map((t) => { + const imageUploadResponse = artwork?.find( + (a) => a.clientId === t.clientId + )?.imageUploadResponse + if (!imageUploadResponse) { + throw new Error(`No artwork found for track ${t.clientId}`) + } + const audioUploadResponse = trackUploads.find( + (ut) => ut.clientId === t.clientId + )!.audioUploadResponse + if (!audioUploadResponse) { + throw new Error(`No audio found for track ${t.clientId}`) + } + return { + clientId: t.clientId, + metadata: t.metadata, + audioUploadResponse, + imageUploadResponse + } + }) + }) + + // Track complete upload analytics + track( + make({ + eventName: Name.TRACK_UPLOAD_COMPLETE_UPLOAD, + kind, + count: tracks.length + }) + ) + + dispatch( + uploadTracksSucceeded({ id: HashId.parse(publishRes.playlistId) }) + ) + } catch (err) { + console.error('Error publishing collection:', err) + track( + make({ + eventName: Name.TRACK_UPLOAD_FAILURE, + kind: uploadType === UploadType.ALBUM ? 'album' : 'playlist' + }) + ) + reportToSentry({ + error: err as Error, + name: 'Upload: Collection Publish', + additionalInfo: { + collectionType: uploadType, + trackCount: tracks.length, + tracks: tracks.map((t) => ({ + title: t.metadata.title, + hasStems: !!t.metadata.stems?.length + })) + }, + feature: Feature.Upload + }) + dispatch(uploadActions.uploadTracksFailed()) + } + } + }, + [ + track, + make, + dispatch, + replaceTrackFiles, + uploadStemFiles, + reportToSentry, + uploadTrackArtworks, + publishTracksAsync, + uploadCollectionArtwork, + publishCollectionAsync + ] + ) + + return { startUpload, finishUpload } +} diff --git a/packages/common/src/api/tan-query/upload/useUploadFiles.ts b/packages/common/src/api/tan-query/upload/useUploadFiles.ts index dfce2d1b4a5..5bd87d52326 100644 --- a/packages/common/src/api/tan-query/upload/useUploadFiles.ts +++ b/packages/common/src/api/tan-query/upload/useUploadFiles.ts @@ -1,162 +1,21 @@ -import type { CrossPlatformFile, FileMetadata, UploadHandle } from '@audius/sdk' -import { - mutationOptions, - useMutation, - type MutationFunctionContext -} from '@tanstack/react-query' +import { type AudiusSdk } from '@audius/sdk' +import { mutationOptions, useMutation } from '@tanstack/react-query' -import { ProgressStatus, uploadActions } from '~/store' - -import { useQueryContext, type QueryContextType } from '../utils' - -import { getUploadStatusOptions } from './useUploadStatus' - -const { updateProgress, resetProgress } = uploadActions +type UploadFile = { + clientId: string +} & ReturnType type UploadFilesParams = { - files: { - clientId: string - stemIndex?: number | null - file: CrossPlatformFile - metadata: FileMetadata - }[] - onUploadCreated?: (clientId: string, upload: UploadHandle) => void -} - -type UploadFileContext = Pick - -const pollFileUploadStatus = async ( - context: UploadFileContext, - queryContext: MutationFunctionContext, - clientId: string, - stemIndex: number | null | undefined, - type: 'audio' | 'art', - uploadId: string, - abortSignal?: AbortSignal, - timeoutS: number = 3600, // 1 hour - delayMs: number = 3000 // 3 seconds -) => { - const t = setTimeout(() => { - context.dispatch( - updateProgress({ - clientId, - stemIndex: stemIndex ?? null, - key: type, - progress: { status: ProgressStatus.ERROR } - }) - ) - throw new Error('Upload timed out') - }, timeoutS * 1000) - - while (true) { - if (abortSignal?.aborted) { - clearTimeout(t) - throw new Error('Upload aborted') - } - - await new Promise((resolve) => setTimeout(resolve, delayMs)) - try { - await queryContext.client.invalidateQueries({ - queryKey: getUploadStatusOptions(context, { uploadId }).queryKey, - refetchType: 'all' - }) - const res = await queryContext.client.fetchQuery( - getUploadStatusOptions(context, { uploadId }) - ) - context.dispatch( - updateProgress({ - clientId, - stemIndex: stemIndex ?? null, - key: type, - progress: { - status: ProgressStatus.PROCESSING, - transcode: res.transcode_progress - } - }) - ) - if ( - res.status === 'done' || - res.status === 'error' || - res.status === 'timeout' - ) { - if (res.status === 'timeout') { - throw new Error('Upload timed out') - } - if (res.status === 'error') { - throw new Error('Upload failed') - } - context.dispatch( - updateProgress({ - clientId, - stemIndex: stemIndex ?? null, - key: type, - progress: { status: ProgressStatus.COMPLETE } - }) - ) - clearTimeout(t) - return res - } - } catch (err) { - // continue polling on error - console.error('Error polling upload status', err) - } - } + files: UploadFile[] } -const getUploadFilesOptions = (context: UploadFileContext) => { +const getUploadFilesOptions = () => { return mutationOptions({ - mutationFn: async (params: UploadFilesParams, queryContext) => { - const sdk = await context.audiusSdk() - + mutationFn: async (params: UploadFilesParams) => { return await Promise.all( - params.files.map(async (fileObj) => { - const abortController = new AbortController() - - // Always start progress from zero. Useful when the audio file gets replaced - context.dispatch( - resetProgress({ - clientId: fileObj.clientId, - stemIndex: fileObj.stemIndex ?? null, - key: fileObj.metadata.template === 'audio' ? 'audio' : 'art' - }) - ) - - const uploadId = await sdk.services.storage.uploadFileV2({ - file: fileObj.file, - onProgress: (loaded, total) => - context.dispatch( - updateProgress({ - clientId: fileObj.clientId, - stemIndex: fileObj.stemIndex ?? null, - key: fileObj.metadata.template === 'audio' ? 'audio' : 'art', - progress: { - status: ProgressStatus.UPLOADING, - loaded, - total - } - }) - ), - metadata: fileObj.metadata, - onUploadCreated: (upload) => { - params.onUploadCreated?.(fileObj.clientId, { - abort: () => { - upload.abort() - abortController.abort() - } - }) - } - }) - - const transcodeRes = await pollFileUploadStatus( - context, - queryContext, - fileObj.clientId, - fileObj.stemIndex, - fileObj.metadata.template === 'audio' ? 'audio' : 'art', - uploadId, - abortController.signal - ) - return { response: transcodeRes, clientId: fileObj.clientId } + params.files.map(async (u) => { + const res = await u.start() + return { ...res, clientId: u.clientId } }) ) } @@ -166,11 +25,8 @@ const getUploadFilesOptions = (context: UploadFileContext) => { type UseUploadFilesOptions = ReturnType export const useUploadFiles = (options?: UseUploadFilesOptions) => { - const context = useQueryContext() - const { dispatch } = context - return useMutation({ ...options, - ...getUploadFilesOptions({ ...context, dispatch }) + ...getUploadFilesOptions() }) } diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index dab5cf7bc12..753df077988 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -1138,7 +1138,7 @@ type TrackUploadOpen = { type TrackUploadStartUploading = { eventName: Name.TRACK_UPLOAD_START_UPLOADING count: number - kind: 'tracks' | 'album' | 'playlist' + kind: 'single_track' | 'multi_track' | 'album' | 'playlist' } type TrackUploadTrackUploading = { eventName: Name.TRACK_UPLOAD_TRACK_UPLOADING @@ -1154,25 +1154,22 @@ type TrackUploadTrackUploading = { type TrackUploadCompleteUpload = { eventName: Name.TRACK_UPLOAD_COMPLETE_UPLOAD count: number - kind: 'tracks' | 'album' | 'playlist' + kind: 'single_track' | 'multi_track' | 'album' | 'playlist' } type TrackUploadSuccess = { eventName: Name.TRACK_UPLOAD_SUCCESS - endpoint: string kind: 'single_track' | 'multi_track' | 'album' | 'playlist' } type TrackUploadFailure = { eventName: Name.TRACK_UPLOAD_FAILURE - endpoint: string kind: 'single_track' | 'multi_track' | 'album' | 'playlist' error?: string } type TrackUploadRejected = { eventName: Name.TRACK_UPLOAD_REJECTED - endpoint: string kind: 'single_track' | 'multi_track' | 'album' | 'playlist' error?: string } diff --git a/packages/common/src/store/ui/modals/replace-track-progress-modal/index.ts b/packages/common/src/store/ui/modals/replace-track-progress-modal/index.ts index 817a4a763ac..1b707e4e19d 100644 --- a/packages/common/src/store/ui/modals/replace-track-progress-modal/index.ts +++ b/packages/common/src/store/ui/modals/replace-track-progress-modal/index.ts @@ -1,10 +1,10 @@ import { createModal } from '../createModal' export type ReplaceTrackProgressModalState = { - progress: { - upload: number - transcode: number - } + loaded?: number + total?: number + transcode?: number + error: boolean } @@ -12,10 +12,9 @@ const replaceTrackProgressModal = createModal({ reducerPath: 'ReplaceTrackProgress', initialState: { isOpen: false, - progress: { - upload: 0, - transcode: 0 - }, + loaded: 0, + total: 0, + transcode: 0, error: false }, sliceSelector: (state) => state.ui.modals diff --git a/packages/common/src/store/upload/actions.ts b/packages/common/src/store/upload/actions.ts index fe416b0dfd3..0f405de89d9 100644 --- a/packages/common/src/store/upload/actions.ts +++ b/packages/common/src/store/upload/actions.ts @@ -69,7 +69,7 @@ export const uploadTracksFailed = () => { export const resetProgress = (payload: { clientId: string stemIndex: number | null - key: 'audio' | 'art' + key: 'audio' | 'image' }) => { return { type: RESET_PROGRESS, payload } } @@ -77,7 +77,7 @@ export const resetProgress = (payload: { export const updateProgress = (payload: { clientId: string stemIndex: number | null - key: 'audio' | 'art' + key: 'audio' | 'image' progress: Progress }) => { return { type: UPDATE_PROGRESS, payload } diff --git a/packages/common/src/store/upload/reducer.ts b/packages/common/src/store/upload/reducer.ts index 7a57111c278..95cf77d21ae 100644 --- a/packages/common/src/store/upload/reducer.ts +++ b/packages/common/src/store/upload/reducer.ts @@ -47,7 +47,7 @@ const initialState: UploadState = { const initialUploadState: ProgressState = { clientId: '', - art: { + image: { status: ProgressStatus.UPLOADING, loaded: 0, total: 0, @@ -63,7 +63,7 @@ const initialUploadState: ProgressState = { } const getInitialProgress = (upload: TrackForUpload | StemUploadWithFile) => { const res = cloneDeep(initialUploadState) - res.art.total = + res.image.total = upload.metadata.artwork && 'file' in upload.metadata.artwork ? (upload.metadata.artwork?.file?.size ?? 0) : 0 @@ -185,7 +185,7 @@ const actionsMap = { ) { newState.uploadProgress[trackIndex].stems[stemIndex] = { ...cloneDeep(initialUploadState), - art: { + image: { status: ProgressStatus.COMPLETE, loaded: 0, total: 0, diff --git a/packages/common/src/store/upload/selectors.ts b/packages/common/src/store/upload/selectors.ts index 8ff3b89e404..f7c04dcce06 100644 --- a/packages/common/src/store/upload/selectors.ts +++ b/packages/common/src/store/upload/selectors.ts @@ -16,14 +16,14 @@ const TRANSCODE_WEIGHT = 1 - UPLOAD_WEIGHT // Should sum to 1 const AUDIO_WEIGHT = 1 -const ART_WEIGHT = 0 +const IMAGE_WEIGHT = 0 /** * Get the upload and transcode status of a track including its stems. */ const trackProgressSummary = ( trackProgress: ProgressState, - key: 'art' | 'audio' + key: 'image' | 'audio' ) => { let loaded = trackProgress[key].status === ProgressStatus.ERROR @@ -58,7 +58,7 @@ const trackProgressSummary = ( * Gets the total upload progress for a particular asset type including stems, * as a percentage between [0, 1] */ -const getKeyUploadProgress = (state: CommonState, key: 'art' | 'audio') => { +const getKeyUploadProgress = (state: CommonState, key: 'image' | 'audio') => { const uploadProgress = state.upload.uploadProgress if (uploadProgress == null) return 0 @@ -79,7 +79,7 @@ const getKeyUploadProgress = (state: CommonState, key: 'art' | 'audio') => { const transcodeProgress = total === 0 ? 0 : transcoded / total const overallProgress = - key === 'art' + key === 'image' ? fileUploadProgress : UPLOAD_WEIGHT * fileUploadProgress + TRANSCODE_WEIGHT * transcodeProgress @@ -88,10 +88,10 @@ const getKeyUploadProgress = (state: CommonState, key: 'art' | 'audio') => { } export const getCombinedUploadPercentage = (state: CommonState) => { - const artProgress = getKeyUploadProgress(state, 'art') + const imageProgress = getKeyUploadProgress(state, 'image') const audioProgress = getKeyUploadProgress(state, 'audio') const percent = floor( - 100 * (ART_WEIGHT * artProgress + AUDIO_WEIGHT * audioProgress) + 100 * (IMAGE_WEIGHT * imageProgress + AUDIO_WEIGHT * audioProgress) ) return clamp(percent, 0, 100) } diff --git a/packages/common/src/store/upload/types.ts b/packages/common/src/store/upload/types.ts index 28ed112298c..b73f71d8755 100644 --- a/packages/common/src/store/upload/types.ts +++ b/packages/common/src/store/upload/types.ts @@ -80,8 +80,8 @@ export type Progress = { export type ProgressState = { clientId: string - /** The progress of the artwork upload. */ - art: Progress + /** The progress of the image upload. */ + image: Progress /** The progress of the audio track upload. */ audio: Progress /** Nested progress for a track's stems (audio only). */ diff --git a/packages/sdk/src/sdk/api/albums/AlbumsApi.test.ts b/packages/sdk/src/sdk/api/albums/AlbumsApi.test.ts index 75b7b94a8a5..7757760e14b 100644 --- a/packages/sdk/src/sdk/api/albums/AlbumsApi.test.ts +++ b/packages/sdk/src/sdk/api/albums/AlbumsApi.test.ts @@ -42,23 +42,26 @@ vitest.mock('../tracks/TrackUploadHelper') vitest.mock('../tracks/TrackUploadHelper') vitest.mock('../generated/default/apis/PlaylistsApi') -vitest.spyOn(Storage.prototype, 'uploadFile').mockImplementation(async () => { +vitest.spyOn(Storage.prototype, 'uploadFile').mockImplementation(() => { return { - id: 'a', - status: 'done', - results: { - '320': 'a' - }, - orig_file_cid: - 'baeaaaiqsea7fukrfrjrugqts6jqfmqhcb5ruc5pjmdk3anj7amoht4d4gemvq', - orig_filename: 'file.wav', - probe: { - format: { - duration: '10' - } - }, - audio_analysis_error_count: 0, - audio_analysis_results: {} + start: async () => ({ + id: 'a', + status: 'done', + results: { + '320': 'a' + }, + orig_file_cid: + 'baeaaaiqsea7fukrfrjrugqts6jqfmqhcb5ruc5pjmdk3anj7amoht4d4gemvq', + orig_filename: 'file.wav', + probe: { + format: { + duration: '10' + } + }, + audio_analysis_error_count: 0, + audio_analysis_results: {} + }), + abort: () => {} } }) diff --git a/packages/sdk/src/sdk/api/playlists/PlaylistsApi.test.ts b/packages/sdk/src/sdk/api/playlists/PlaylistsApi.test.ts index 3d9f03d2261..ebfec94f0a3 100644 --- a/packages/sdk/src/sdk/api/playlists/PlaylistsApi.test.ts +++ b/packages/sdk/src/sdk/api/playlists/PlaylistsApi.test.ts @@ -30,25 +30,28 @@ vitest.mock('../tracks/TrackUploadHelper') vitest.mock('../tracks/TrackUploadHelper') vitest.mock('../generated/default/apis/PlaylistsApi') -vitest.spyOn(Storage.prototype, 'uploadFile').mockImplementation(async () => { - return { - id: 'a', - status: 'done', - results: { - '320': 'a' - }, - orig_file_cid: - 'baeaaaiqsea7fukrfrjrugqts6jqfmqhcb5ruc5pjmdk3anj7amoht4d4gemvq', - orig_filename: 'file.wav', - probe: { - format: { - duration: '10' - } - }, - audio_analysis_error_count: 0, - audio_analysis_results: {} - } -}) +vitest.spyOn(Storage.prototype, 'uploadFile').mockImplementation(() => ({ + start: async () => { + return { + id: 'a', + status: 'done', + results: { + '320': 'a' + }, + orig_file_cid: + 'baeaaaiqsea7fukrfrjrugqts6jqfmqhcb5ruc5pjmdk3anj7amoht4d4gemvq', + orig_filename: 'file.wav', + probe: { + format: { + duration: '10' + } + }, + audio_analysis_error_count: 0, + audio_analysis_results: {} + } + }, + abort: () => {} +})) vitest .spyOn(TrackUploadHelper.prototype, 'generateId' as any) diff --git a/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts b/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts index 0b7cd50f3ec..f0c77a7eb35 100644 --- a/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts +++ b/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts @@ -436,11 +436,15 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { const [coverArtResponse, ...audioResponses] = await Promise.all([ retry3( async () => - await this.storage.uploadFile({ - file: coverArtFile, - onProgress, - template: 'img_square' - }), + await this.storage + .uploadFile({ + file: coverArtFile, + onProgress, + metadata: { + template: 'img_square' + } + }) + .start(), (e) => { this.logger.info('Retrying uploadPlaylistCoverArt', e) } @@ -449,14 +453,18 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { async (trackFile, idx) => await retry3( async () => - await this.storage.uploadFile({ - file: trackFile, - onProgress, - template: 'audio', - options: this.trackUploadHelper.extractMediorumUploadOptions( - trackMetadatas[idx]! - ) - }), + await this.storage + .uploadFile({ + file: trackFile, + onProgress, + metadata: { + template: 'audio', + ...this.trackUploadHelper.extractMediorumUploadOptions( + trackMetadatas[idx]! + ) + } + }) + .start(), (e) => { this.logger.info('Retrying uploadTrackAudio', e) } @@ -562,11 +570,15 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { coverArtFile && (await retry3( async () => - await this.storage.uploadFile({ - file: coverArtFile, - onProgress, - template: 'img_square' - }), + await this.storage + .uploadFile({ + file: coverArtFile, + onProgress, + metadata: { + template: 'img_square' + } + }) + .start(), (e) => { this.logger.info('Retrying uploadPlaylistCoverArt', e) } @@ -612,11 +624,15 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { coverArtFile && (await retry3( async () => - await this.storage.uploadFile({ - file: coverArtFile, - onProgress, - template: 'img_square' - }), + await this.storage + .uploadFile({ + file: coverArtFile, + onProgress, + metadata: { + template: 'img_square' + } + }) + .start(), (e) => { this.logger.info('Retrying uploadPlaylistCoverArt', e) } diff --git a/packages/sdk/src/sdk/api/tracks/TrackUploadHelper.ts b/packages/sdk/src/sdk/api/tracks/TrackUploadHelper.ts index 15d61c8b58f..84730603e78 100644 --- a/packages/sdk/src/sdk/api/tracks/TrackUploadHelper.ts +++ b/packages/sdk/src/sdk/api/tracks/TrackUploadHelper.ts @@ -58,34 +58,44 @@ export class TrackUploadHelper extends BaseAPI { } public populateTrackMetadataWithUploadResponse( - trackMetadata: PlaylistTrackMetadata, - audioResponse: UploadResponse, + trackMetadata: Partial, + audioResponse?: UploadResponse, coverArtResponse?: UploadResponse ) { - return { - ...trackMetadata, - trackSegments: [], - trackCid: audioResponse.results['320'], - previewCid: - trackMetadata.previewStartSeconds !== undefined && - trackMetadata.previewStartSeconds !== null - ? audioResponse.results[ - `320_preview|${trackMetadata.previewStartSeconds}` - ] - : trackMetadata.previewCid, - origFileCid: audioResponse.orig_file_cid, - origFilename: audioResponse.orig_filename || trackMetadata.origFilename, - audioUploadId: audioResponse.id, - coverArtSizes: coverArtResponse?.orig_file_cid, - duration: parseInt(audioResponse?.probe?.format?.duration ?? '0', 10), - bpm: audioResponse.audio_analysis_results?.bpm - ? audioResponse.audio_analysis_results.bpm - : trackMetadata.bpm, - musicalKey: audioResponse.audio_analysis_results?.key - ? audioResponse.audio_analysis_results.key - : trackMetadata.musicalKey, - audioAnalysisErrorCount: audioResponse.audio_analysis_error_count || 0 + let updated: Partial & { coverArtSizes?: string } = { + ...trackMetadata } + if (audioResponse) { + updated = { + ...updated, + trackCid: audioResponse.results['320'], + previewCid: + trackMetadata.previewStartSeconds !== undefined && + trackMetadata.previewStartSeconds !== null + ? audioResponse.results[ + `320_preview|${trackMetadata.previewStartSeconds}` + ] + : trackMetadata.previewCid, + origFileCid: audioResponse.orig_file_cid, + origFilename: audioResponse.orig_filename || trackMetadata.origFilename, + audioUploadId: audioResponse.id, + duration: parseInt(audioResponse?.probe?.format?.duration ?? '0', 10), + bpm: audioResponse.audio_analysis_results?.bpm + ? audioResponse.audio_analysis_results.bpm + : trackMetadata.bpm, + musicalKey: audioResponse.audio_analysis_results?.key + ? audioResponse.audio_analysis_results.key + : trackMetadata.musicalKey, + audioAnalysisErrorCount: audioResponse.audio_analysis_error_count || 0 + } + } + if (coverArtResponse) { + updated = { + ...updated, + coverArtSizes: coverArtResponse.orig_file_cid + } + } + return updated } public extractMediorumUploadOptions(metadata: PlaylistTrackMetadata) { diff --git a/packages/sdk/src/sdk/api/tracks/TracksApi.test.ts b/packages/sdk/src/sdk/api/tracks/TracksApi.test.ts index 9cec5d371fb..6110b84e7d7 100644 --- a/packages/sdk/src/sdk/api/tracks/TracksApi.test.ts +++ b/packages/sdk/src/sdk/api/tracks/TracksApi.test.ts @@ -39,25 +39,28 @@ vitest.mock('../../services/StorageNodeSelector') vitest.mock('../../services/Storage') vitest.mock('./TrackUploadHelper') -vitest.spyOn(Storage.prototype, 'uploadFile').mockImplementation(async () => { - return { - id: 'a', - status: 'done', - results: { - '320': 'a' - }, - orig_file_cid: - 'baeaaaiqsea7fukrfrjrugqts6jqfmqhcb5ruc5pjmdk3anj7amoht4d4gemvq', - orig_filename: 'file.wav', - probe: { - format: { - duration: '10' - } - }, - audio_analysis_error_count: 0, - audio_analysis_results: {} - } -}) +vitest.spyOn(Storage.prototype, 'uploadFile').mockImplementation(() => ({ + start: async () => { + return { + id: 'a', + status: 'done', + results: { + '320': 'a' + }, + orig_file_cid: + 'baeaaaiqsea7fukrfrjrugqts6jqfmqhcb5ruc5pjmdk3anj7amoht4d4gemvq', + orig_filename: 'file.wav', + probe: { + format: { + duration: '10' + } + }, + audio_analysis_error_count: 0, + audio_analysis_results: {} + } + }, + abort: () => {} +})) vitest .spyOn(TrackUploadHelper.prototype, 'generateId' as any) @@ -145,7 +148,7 @@ describe('TracksApi', () => { it('uploads a track if valid metadata is provided', async () => { const result = await tracks.uploadTrack({ userId: '7eP5n', - coverArtFile: { + imageFile: { buffer: pngFile, name: 'coverArt' }, @@ -154,7 +157,7 @@ describe('TracksApi', () => { genre: Genre.ELECTRONIC, mood: Mood.TENDER }, - trackFile: { + audioFile: { buffer: wavFile, name: 'trackArt' } @@ -171,14 +174,14 @@ describe('TracksApi', () => { await expect(async () => { await tracks.uploadTrack({ userId: '7eP5n', - coverArtFile: { + imageFile: { buffer: pngFile, name: 'coverArt' }, metadata: { title: 'BachGavotte' } as any, - trackFile: { + audioFile: { buffer: wavFile, name: 'trackArt' } @@ -192,7 +195,7 @@ describe('TracksApi', () => { const result = await tracks.updateTrack({ userId: '7eP5n', trackId: 'ogRRByg', - coverArtFile: { + imageFile: { buffer: pngFile, name: 'coverArt' }, @@ -214,7 +217,7 @@ describe('TracksApi', () => { await tracks.updateTrack({ userId: '7eP5n', trackId: 'ogRRByg', - coverArtFile: { + imageFile: { buffer: pngFile, name: 'coverArt' }, diff --git a/packages/sdk/src/sdk/api/tracks/TracksApi.ts b/packages/sdk/src/sdk/api/tracks/TracksApi.ts index 382e4779a96..6f30892a339 100644 --- a/packages/sdk/src/sdk/api/tracks/TracksApi.ts +++ b/packages/sdk/src/sdk/api/tracks/TracksApi.ts @@ -15,7 +15,7 @@ import { } from '../../services/EntityManager/types' import type { LoggerService } from '../../services/Logger' import type { SolanaClient } from '../../services/Solana/programs/SolanaClient' -import type { StorageService } from '../../services/Storage' +import type { StorageService, UploadHandle } from '../../services/Storage' import { decodeHashId, encodeHashId } from '../../utils/hashId' import { getLocation } from '../../utils/location' import { parseParams } from '../../utils/parseParams' @@ -151,72 +151,65 @@ export class TracksApi extends GeneratedTracksApi { /** @hidden * Upload track files, does not write to chain */ - async uploadTrackFiles(params: UploadTrackFilesRequest) { - // Parse inputs - this.logger.info('Parsing inputs') - const { - userId, - trackFile, - coverArtFile, - metadata: parsedMetadata, - onProgress - } = await parseParams('uploadTrackFiles', UploadTrackFilesSchema)(params) - - // Transform metadata - this.logger.info('Transforming metadata') - const metadata = this.trackUploadHelper.transformTrackUploadMetadata( - parsedMetadata, - userId - ) - - // Upload track audio and cover art to storage node - this.logger.info('Uploading track audio and cover art') - const [coverArtResponse, audioResponse] = await Promise.all([ - coverArtFile - ? retry3( - async () => - await this.storage.uploadFile({ - file: coverArtFile, - onProgress, - template: 'img_square' - }), - (e) => { - this.logger.info('Retrying uploadTrackCoverArt', e) - } - ) - : Promise.resolve(undefined), - retry3( - async () => - await this.storage.uploadFile({ - file: trackFile, - onProgress, - template: 'audio', - options: - this.trackUploadHelper.extractMediorumUploadOptions(metadata) - }), - (e) => { - this.logger.info('Retrying uploadTrackAudio', e) - } - ) - ]) - - // Update metadata to include uploaded CIDs - return this.trackUploadHelper.populateTrackMetadataWithUploadResponse( - metadata, - audioResponse, - coverArtResponse - ) + uploadTrackFiles(params: UploadTrackFilesRequest) { + let audioUpload: UploadHandle | null = null + let imageUpload: UploadHandle | null = null + return { + start: async () => { + const { audioFile, imageFile, fileMetadata, onProgress } = + await parseParams('uploadTrackFiles', UploadTrackFilesSchema)(params) + + imageUpload = imageFile + ? this.storage.uploadFile({ + file: imageFile, + onProgress, + metadata: { + template: 'img_square', + filename: imageFile.name ?? undefined, + filetype: imageFile.type ?? undefined + } + }) + : null + + audioUpload = audioFile + ? this.storage.uploadFile({ + file: audioFile, + onProgress, + metadata: { + template: 'audio', + filename: audioFile.name ?? undefined, + filetype: audioFile.type ?? undefined, + placementHosts: fileMetadata?.placementHosts, + previewStartSeconds: fileMetadata?.previewStartSeconds + } + }) + : null + const [audioUploadResponse, imageUploadResponse] = await Promise.all([ + audioUpload?.start(), + imageUpload?.start() + ]) + this.logger.info('Successfully uploaded track files') + return { audioUploadResponse, imageUploadResponse } + }, + abort: (shouldTerminate?: boolean) => { + audioUpload?.abort(shouldTerminate) + imageUpload?.abort(shouldTerminate) + } + } } /** @hidden * Publishes a track that was uploaded using storage node uploadFileV2 uploads. */ - async publishTrack(params: PublishTrackRequest) { + async publishTrack( + params: PublishTrackRequest, + advancedOptions?: AdvancedOptions + ) { const { userId, metadata: parsedMetadata, audioUploadResponse, - artUploadResponse + imageUploadResponse } = await parseParams('publishTrack', PublishTrackSchema)(params) const metadata = this.trackUploadHelper.transformTrackUploadMetadata( @@ -228,10 +221,14 @@ export class TracksApi extends GeneratedTracksApi { this.trackUploadHelper.populateTrackMetadataWithUploadResponse( metadata, audioUploadResponse, - artUploadResponse + imageUploadResponse ) - return this.writeTrackToChain(params.userId, populatedMetadata) + return this.writeTrackToChain( + params.userId, + populatedMetadata, + advancedOptions + ) } /** @hidden @@ -285,7 +282,7 @@ export class TracksApi extends GeneratedTracksApi { } } - /** @hidden + /** * Upload a track */ async uploadTrack( @@ -296,12 +293,23 @@ export class TracksApi extends GeneratedTracksApi { await parseParams('uploadTrack', UploadTrackSchema)(params) // Upload track files - const metadata = await this.uploadTrackFiles( - params as UploadTrackFilesRequest - ) + const { audioUploadResponse, imageUploadResponse } = + await this.uploadTrackFiles(params as UploadTrackFilesRequest).start() + + if (!audioUploadResponse || !imageUploadResponse) { + throw new Error('uploadTrack: Missing upload responses') + } // Write track metadata to chain - return this.writeTrackToChain(params.userId, metadata, advancedOptions) + return this.publishTrack( + { + userId: params.userId, + metadata: params.metadata, + audioUploadResponse, + imageUploadResponse + }, + advancedOptions + ) } /** @hidden @@ -315,7 +323,8 @@ export class TracksApi extends GeneratedTracksApi { const { userId, trackId, - coverArtFile, + audioFile, + imageFile, metadata: parsedMetadata, onProgress, generatePreview @@ -327,28 +336,28 @@ export class TracksApi extends GeneratedTracksApi { userId ) - // Upload track cover art to storage node - const coverArtResp = - coverArtFile && - (await retry3( - async () => - await this.storage.uploadFile({ - file: coverArtFile, - onProgress, - template: 'img_square' - }), - (e) => { - this.logger.info('Retrying uploadTrackCoverArt', e) - } - )) + const { audioUploadResponse, imageUploadResponse } = + await this.uploadTrackFiles({ + audioFile, + imageFile, + fileMetadata: { + placementHosts: parsedMetadata.placementHosts, + previewStartSeconds: parsedMetadata.previewStartSeconds + }, + onProgress + }).start() // Update metadata to include uploaded CIDs - const updatedMetadata = { - ...metadata, - ...(coverArtResp ? { coverArtSizes: coverArtResp.id } : {}) - } + const updatedMetadata = + this.trackUploadHelper.populateTrackMetadataWithUploadResponse( + metadata, + audioUploadResponse, + imageUploadResponse + ) - if (generatePreview) { + // Generate preview if requested and no audio file was uploaded + // (as that would handle the preview generation already) + if (generatePreview && !audioFile) { if (updatedMetadata.previewStartSeconds === undefined) { throw new Error('No track preview start time specified') } @@ -356,7 +365,6 @@ export class TracksApi extends GeneratedTracksApi { throw new Error('Missing required audio_upload_id') } - // Generate track preview const previewCid = await retry3( async () => await this.storage.generatePreview({ diff --git a/packages/sdk/src/sdk/api/tracks/types.ts b/packages/sdk/src/sdk/api/tracks/types.ts index 3570cba486f..09bc34c44f6 100644 --- a/packages/sdk/src/sdk/api/tracks/types.ts +++ b/packages/sdk/src/sdk/api/tracks/types.ts @@ -2,7 +2,10 @@ import type { WalletAdapter } from '@solana/wallet-adapter-base' import { z } from 'zod' import { PublicKeySchema } from '../../services/Solana' -import { ProgressHandler } from '../../services/Storage/types' +import { + ProgressHandlerSchema, + type ProgressHandler +} from '../../services/Storage/types' import { DDEXResourceContributor, DDEXCopyright, @@ -196,10 +199,10 @@ export type TrackMetadata = z.input export const UploadTrackSchema = z .object({ userId: HashId, - coverArtFile: ImageFile, + audioFile: AudioFile, + imageFile: ImageFile, metadata: UploadTrackMetadataSchema.strict(), - onProgress: z.optional(z.function()), - trackFile: AudioFile + onProgress: z.optional(ProgressHandlerSchema) }) .strict() @@ -214,20 +217,18 @@ export type UploadTrackRequest = Omit< export const UploadTrackFilesSchema = z .object({ - userId: HashId, - coverArtFile: z.optional(ImageFile), - metadata: UploadTrackMetadataSchema.extend({ - genre: z.optional(z.enum(Object.values(Genre) as [Genre, ...Genre[]])) - }).strict(), - onProgress: z.optional(z.function()), - trackFile: AudioFile + audioFile: z.optional(AudioFile), + imageFile: z.optional(ImageFile), + fileMetadata: z + .object({ + placementHosts: z.string().optional(), + previewStartSeconds: z.number().optional() + }) + .optional(), + onProgress: z.optional(ProgressHandlerSchema) }) .strict() -export type TrackFilesMetadata = z.input< - typeof UploadTrackFilesSchema ->['metadata'] - export type UploadTrackFilesRequest = Omit< z.input, 'onProgress' @@ -242,9 +243,10 @@ export const UpdateTrackSchema = z userId: HashId, trackId: HashId, metadata: UploadTrackMetadataSchema.strict().partial(), + audioFile: z.optional(AudioFile), + imageFile: z.optional(ImageFile), generatePreview: z.optional(z.boolean()), - coverArtFile: z.optional(ImageFile), - onProgress: z.optional(z.function()) + onProgress: z.optional(ProgressHandlerSchema) }) .strict() @@ -421,11 +423,9 @@ export type UploadResponse = z.input export const PublishTrackSchema = z .object({ userId: HashId, - metadata: UploadTrackMetadataSchema.extend({ - genre: z.optional(z.enum(Object.values(Genre) as [Genre, ...Genre[]])) - }).strict(), + metadata: UploadTrackMetadataSchema.strict(), audioUploadResponse: UploadResponseSchema, - artUploadResponse: UploadResponseSchema, + imageUploadResponse: UploadResponseSchema, stemsUploadResponses: z.array(UploadResponseSchema).optional() }) .strict() diff --git a/packages/sdk/src/sdk/api/users/UsersApi.test.ts b/packages/sdk/src/sdk/api/users/UsersApi.test.ts index 1d554175c5f..cf7df74c39b 100644 --- a/packages/sdk/src/sdk/api/users/UsersApi.test.ts +++ b/packages/sdk/src/sdk/api/users/UsersApi.test.ts @@ -37,25 +37,28 @@ const pngFile = fs.readFileSync( vitest.mock('../../services/EntityManager') -vitest.spyOn(Storage.prototype, 'uploadFile').mockImplementation(async () => { - return { - id: 'a', - status: 'done', - results: { - '320': 'a' - }, - orig_file_cid: - 'baeaaaiqsea7fukrfrjrugqts6jqfmqhcb5ruc5pjmdk3anj7amoht4d4gemvq', - orig_filename: 'file.wav', - probe: { - format: { - duration: '10' - } - }, - audio_analysis_error_count: 0, - audio_analysis_results: {} - } -}) +vitest.spyOn(Storage.prototype, 'uploadFile').mockImplementation(() => ({ + start: async () => { + return { + id: 'a', + status: 'done', + results: { + '320': 'a' + }, + orig_file_cid: + 'baeaaaiqsea7fukrfrjrugqts6jqfmqhcb5ruc5pjmdk3anj7amoht4d4gemvq', + orig_filename: 'file.wav', + probe: { + format: { + duration: '10' + } + }, + audio_analysis_error_count: 0, + audio_analysis_results: {} + } + }, + abort: () => {} +})) vitest .spyOn(EntityManagerClient.prototype, 'manageEntity') diff --git a/packages/sdk/src/sdk/api/users/UsersApi.ts b/packages/sdk/src/sdk/api/users/UsersApi.ts index 6696e964db6..cee604a1af1 100644 --- a/packages/sdk/src/sdk/api/users/UsersApi.ts +++ b/packages/sdk/src/sdk/api/users/UsersApi.ts @@ -104,11 +104,15 @@ export class UsersApi extends GeneratedUsersApi { profilePictureFile && retry3( async () => - await this.storage.uploadFile({ - file: profilePictureFile, - onProgress, - template: 'img_square' - }), + await this.storage + .uploadFile({ + file: profilePictureFile, + onProgress, + metadata: { + template: 'img_square' + } + }) + .start(), (e) => { this.logger.info('Retrying uploadProfilePicture', e) } @@ -116,11 +120,15 @@ export class UsersApi extends GeneratedUsersApi { coverArtFile && retry3( async () => - await this.storage.uploadFile({ - file: coverArtFile, - onProgress, - template: 'img_backdrop' - }), + await this.storage + .uploadFile({ + file: coverArtFile, + onProgress, + metadata: { + template: 'img_backdrop' + } + }) + .start(), (e) => { this.logger.info('Retrying uploadProfileCoverArt', e) } @@ -206,11 +214,15 @@ export class UsersApi extends GeneratedUsersApi { profilePictureFile && retry3( async () => - await this.storage.uploadFile({ - file: profilePictureFile, - onProgress, - template: 'img_square' - }), + await this.storage + .uploadFile({ + file: profilePictureFile, + onProgress, + metadata: { + template: 'img_square' + } + }) + .start(), (e) => { this.logger.info('Retrying uploadProfilePicture', e) } @@ -218,11 +230,15 @@ export class UsersApi extends GeneratedUsersApi { coverArtFile && retry3( async () => - await this.storage.uploadFile({ - file: coverArtFile, - onProgress, - template: 'img_backdrop' - }), + await this.storage + .uploadFile({ + file: coverArtFile, + onProgress, + metadata: { + template: 'img_backdrop' + } + }) + .start(), (e) => { this.logger.info('Retrying uploadProfileCoverArt', e) } diff --git a/packages/sdk/src/sdk/services/Storage/Storage.ts b/packages/sdk/src/sdk/services/Storage/Storage.ts index fcf49af6038..17de36c0f1d 100644 --- a/packages/sdk/src/sdk/services/Storage/Storage.ts +++ b/packages/sdk/src/sdk/services/Storage/Storage.ts @@ -1,10 +1,6 @@ -import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' -import FormData from 'form-data' import * as tus from 'tus-js-client' import { productionConfig } from '../../config/production' -import { isNodeFile } from '../../types/File' -import type { CrossPlatformFile } from '../../types/File' import fetch from '../../utils/fetch' import { mergeConfigWithDefaults } from '../../utils/mergeConfigs' import { wait } from '../../utils/wait' @@ -13,14 +9,13 @@ import type { StorageNodeSelectorService } from '../StorageNodeSelector' import { getDefaultStorageServiceConfig } from './getDefaultConfig' import type { - FileMetadata, FileTemplate, ProgressHandler, StorageService, StorageServiceConfig, StorageServiceConfigInternal, UploadResponse, - UploadHandle + UploadFileParams } from './types' const MAX_TRACK_TRANSCODE_TIMEOUT = 3600000 // 1 hour @@ -46,153 +41,93 @@ export class Storage implements StorageService { } /** - * Upload a file to a content node - * @param file - * @param onProgress - * @param template - * @param options - * @returns + * Upload a file to a validator */ - async uploadFile({ - file, - onProgress, - template, - options = {} - }: { - file: CrossPlatformFile - onProgress?: ProgressHandler - template: FileTemplate - options?: { [key: string]: string } - }) { - const formData: FormData = new FormData() - formData.append('template', template) - Object.keys(options).forEach((key) => { - formData.append(key, `${options[key]}`) - }) - - const formDataFile = - 'uri' in file - ? { - ...file, - // NOTE this is required for react-native - // certain characters in the file name make formData invalid - name: file.name - ? encodeURIComponent(file.name.replace(/[()]/g, '')) - : 'blob' - } - : file - - formData.append( - 'files', - isNodeFile(formDataFile) ? formDataFile.buffer : formDataFile, - file.name ?? 'blob' - ) + uploadFile({ file, onProgress, metadata }: UploadFileParams) { + const ac = new AbortController() + let upload: tus.Upload | undefined + let uploadPromise: Promise | undefined - // Using axios for now because it supports upload progress, - // and Node doesn't support XmlHttpRequest - let response: AxiosResponse | null = null - const request: AxiosRequestConfig = { - method: 'post', - maxContentLength: Infinity, - data: formData, - headers: { - ...(formData.getBoundary - ? { - 'Content-Type': `multipart/form-data; boundary=${formData.getBoundary()}` - } - : undefined) - }, - onUploadProgress: (progressEvent) => { - const progress = { - upload: { loaded: progressEvent.loaded, total: progressEvent.total } + return { + start: async () => { + if (uploadPromise) { + return uploadPromise } - onProgress?.( - template === 'audio' ? { audio: progress } : { art: progress } - ) - } - } + uploadPromise = new Promise((resolve, reject) => { + this.storageNodeSelector + .getSelectedNode() + .then((selectedNode) => { + if (!selectedNode) { + reject(new Error('No node available')) + return + } + upload = new tus.Upload(file as File, { + endpoint: `${selectedNode}/files/`, + retryDelays: [0, 3000, 5000, 10000, 20000], + chunkSize: 100_000_000, // 100MB + removeFingerprintOnSuccess: true, + metadata: { + filename: metadata.filename || file.name || 'file', + filetype: + metadata.filetype || + file.type || + 'application/octet-stream', + template: metadata.template, + ...(metadata.placementHosts + ? { placementHosts: metadata.placementHosts } + : {}), + ...(metadata.previewStartSeconds !== undefined + ? { + previewStartSeconds: + metadata.previewStartSeconds.toString() + } + : {}) + }, + onError: reject, + onProgress: (bytesUploaded: number, bytesTotal: number) => { + onProgress?.( + metadata.template === 'audio' ? 'audio' : 'image', + { + loaded: bytesUploaded, + total: bytesTotal + } + ) + }, + onSuccess: async () => { + const uploadId = upload?.url?.split('/').pop() + if (!uploadId) { + reject(new Error('No upload ID received')) + return + } + const res = await this.pollProcessingStatus( + uploadId, + metadata.template, + onProgress, + ac.signal + ) + resolve(res) + } + }) - let lastErr - for ( - let selectedNode = await this.storageNodeSelector.getSelectedNode(); - !this.storageNodeSelector.triedSelectingAllNodes(); - selectedNode = await this.storageNodeSelector.getSelectedNode(true) - ) { - request.url = `${selectedNode!}/uploads` - try { - response = await axios(request) - // Server will sometimes return empty array in case of error - if (response?.data?.length > 0) { - break - } - } catch (e: any) { - lastErr = e // keep trying other nodes + upload + ?.findPreviousUploads() + .then((previousUpload) => { + if (previousUpload?.length && previousUpload[0]) { + upload?.resumeFromPreviousUpload(previousUpload[0]) + } + upload?.start() + }) + .catch(reject) + }) + .catch(reject) + }) + return uploadPromise + }, + abort: (shouldTerminate = false) => { + upload?.abort(shouldTerminate) + ac.abort() } } - - // Covers no response or empty response - if (!response?.data?.length) { - const msg = `Error sending storagev2 upload request, tried all healthy storage nodes. Last error: ${lastErr}` - this.logger.error(msg) - throw new Error(msg) - } - - return await this.pollProcessingStatus( - response.data[0].id, - template, - onProgress - ) - } - - async uploadFileV2({ - file, - onProgress, - metadata, - onUploadCreated - }: { - file: CrossPlatformFile - onProgress: (loadedBytes: number, totalBytes: number) => void - metadata: FileMetadata - onUploadCreated?: (upload: UploadHandle) => void - }): Promise { - const selectedNode = await this.storageNodeSelector.getSelectedNode() - if (!selectedNode) { - throw new Error('No node available') - } - return new Promise((resolve, reject) => { - const upload = new tus.Upload(file as File, { - endpoint: `${selectedNode}/files/`, - retryDelays: [0, 3000, 5000, 10000, 20000], - chunkSize: 100_000_000, // 100MB - removeFingerprintOnSuccess: true, - metadata: { - filename: metadata.filename || file.name || 'file', - filetype: - metadata.filetype || file.type || 'application/octet-stream', - ...metadata - }, - onError: reject, - onProgress, - onSuccess: () => { - const uploadId = upload.url?.split('/').pop() - if (!uploadId) { - reject(new Error('No upload ID received')) - return - } - resolve(uploadId) - } - }) - - // Call callback immediately with upload handle (for cancellation) - onUploadCreated?.(upload) - - upload.findPreviousUploads().then((previousUploads) => { - if (previousUploads.length && previousUploads[0]) { - upload.resumeFromPreviousUpload(previousUploads[0]) - } - upload.start() - }) - }) } async getUploadStatus(uploadId: string): Promise { @@ -230,12 +165,20 @@ export class Storage implements StorageService { throw new Error('No content node available') } - const response = await axios({ - method: 'post', - url: `${contentNodeEndpoint}/generate_preview/${cid}/${secondOffset}` - }) + const response = await fetch( + `${contentNodeEndpoint}/generate_preview/${cid}/${secondOffset}`, + { + method: 'POST' + } + ) + if (!response.ok) { + throw new Error( + `Failed to generate preview for cid ${cid} at offset ${secondOffset}, status: ${response.status}` + ) + } - return response.data.cid + const data = await response.json() + return data.cid } /** @@ -247,7 +190,8 @@ export class Storage implements StorageService { private async pollProcessingStatus( id: string, template: FileTemplate, - onProgress?: ProgressHandler + onProgress?: ProgressHandler, + abortSignal?: AbortSignal ) { const start = Date.now() let lastProgressUpdate = Date.now() @@ -260,6 +204,9 @@ export class Storage implements StorageService { while (Date.now() - start < maxPollingMs) { try { + if (abortSignal?.aborted) { + throw new Error('Upload aborted') + } const resp = await this.getProcessingStatus(id) if (template === 'audio' && resp.transcode_progress) { // Only update lastProgressUpdate if the progress has increased @@ -275,10 +222,8 @@ export class Storage implements StorageService { ) } - onProgress?.({ - audio: { - transcode: { decimal: resp.transcode_progress } - } + onProgress?.(template === 'audio' ? 'audio' : 'image', { + transcode: resp.transcode_progress }) } if (resp?.status === 'done') { diff --git a/packages/sdk/src/sdk/services/Storage/types.ts b/packages/sdk/src/sdk/services/Storage/types.ts index e69e7403255..2730b11f4df 100644 --- a/packages/sdk/src/sdk/services/Storage/types.ts +++ b/packages/sdk/src/sdk/services/Storage/types.ts @@ -1,3 +1,5 @@ +import { z } from 'zod' + import type { CrossPlatformFile } from '../../types/File' import type { LoggerService } from '../Logger' import type { StorageNodeSelectorService } from '../StorageNodeSelector' @@ -7,10 +9,14 @@ import type { StorageNodeSelectorService } from '../StorageNodeSelector' * Provides methods to interact with the upload without exposing the underlying implementation. */ export interface UploadHandle { + /** + * Starts the upload + */ + start: () => Promise /** * Aborts the upload */ - abort: () => void + abort: (shouldTerminate?: boolean) => void } export type StorageServiceConfigInternal = { @@ -27,49 +33,40 @@ export type StorageServiceConfig = Partial & { storageNodeSelector: StorageNodeSelectorService } -export type ProgressHandler = ( - progress: - | { - art: { - upload?: { loaded: number; total: number } - transcode?: { decimal: number } - resize?: undefined - } - } - | { - audio: { - upload?: { loaded: number; total: number } - transcode?: { decimal: number } - resize?: undefined - } - } -) => void +const ProgressUpdateArgsSchema = z.object({ + loaded: z.number().optional(), + total: z.number().optional(), + transcode: z.number().optional() +}) + +export const ProgressHandlerSchema = z + .function() + .args(z.enum(['audio', 'image']), ProgressUpdateArgsSchema) + .returns(z.void()) + +const UploadCreatedHandlerSchema = z + .function() + .args( + z.object({ + abort: z.function().args(z.boolean().optional()).returns(z.void()) + }) + ) + .returns(z.void()) + +export type UploadCreatedHandler = z.input + +export type ProgressHandler = z.input export type FileTemplate = 'audio' | 'img_square' | 'img_backdrop' +export type UploadFileParams = { + file: CrossPlatformFile + onProgress?: ProgressHandler + metadata: FileMetadata +} + export type StorageService = { - uploadFile: ({ - file, - onProgress, - template, - options - }: { - file: CrossPlatformFile - onProgress?: ProgressHandler - template: FileTemplate - options?: { [key: string]: string } - }) => Promise - uploadFileV2: ({ - file, - onProgress, - metadata, - onUploadCreated - }: { - file: CrossPlatformFile - onProgress: (loadedBytes: number, totalBytes: number) => void - metadata: FileMetadata - onUploadCreated?: (upload: UploadHandle) => void - }) => Promise + uploadFile: (params: UploadFileParams) => UploadHandle getUploadStatus: (uploadId: string) => Promise generatePreview: ({ cid, @@ -115,6 +112,6 @@ export type FileMetadata = { filetype?: string template: 'audio' | 'img_square' | 'img_backdrop' userWallet?: string - previewStartSeconds?: string + previewStartSeconds?: number placementHosts?: string } diff --git a/packages/web/src/common/store/cache/tracks/sagas.ts b/packages/web/src/common/store/cache/tracks/sagas.ts index 475dcf4def7..24a00058ecd 100644 --- a/packages/web/src/common/store/cache/tracks/sagas.ts +++ b/packages/web/src/common/store/cache/tracks/sagas.ts @@ -221,7 +221,7 @@ function* confirmEditTrack( yield* call([sdk.tracks, sdk.tracks.updateTrack], { userId: Id.parse(userId), trackId: Id.parse(trackId), - coverArtFile: coverArtFile + imageFile: coverArtFile ? fileToSdk(coverArtFile, 'cover_art') : undefined, metadata: trackMetadataForUploadToSdk(formFields), diff --git a/packages/web/src/components/nav/desktop/useNavUploadStatus.ts b/packages/web/src/components/nav/desktop/useNavUploadStatus.ts index b2c1fc42eb3..5c432f6d8e6 100644 --- a/packages/web/src/components/nav/desktop/useNavUploadStatus.ts +++ b/packages/web/src/components/nav/desktop/useNavUploadStatus.ts @@ -31,8 +31,8 @@ export const useNavUploadStatus = () => { const upload = useSelector((state: CommonState) => state.upload) const uploadType = upload.formState?.uploadType ?? UploadType.INDIVIDUAL_TRACK const uploadCompletionRoute = useUploadCompletionRoute({ - upload, - accountHandle, + id: upload.completionId, + accountHandle: accountHandle || '', uploadType }) diff --git a/packages/web/src/components/replace-track-progress-modal/ReplaceTrackProgressModal.tsx b/packages/web/src/components/replace-track-progress-modal/ReplaceTrackProgressModal.tsx index 0292068f9ba..ec3a1dd089f 100644 --- a/packages/web/src/components/replace-track-progress-modal/ReplaceTrackProgressModal.tsx +++ b/packages/web/src/components/replace-track-progress-modal/ReplaceTrackProgressModal.tsx @@ -22,9 +22,10 @@ const messages = { export const ReplaceTrackProgressModal = () => { const { data, isOpen, onClose } = useReplaceTrackProgressModal() - const { progress, error } = data + const { loaded, total, transcode, error } = data - const uploadProgress = Math.min(progress.upload + progress.transcode, 2) / 2 + const uploadProgress = + Math.min((loaded && total ? loaded / total : 0) + (transcode ?? 0), 2) / 2 const isUploadComplete = uploadProgress >= 1 return ( diff --git a/packages/web/src/hooks/useReplaceTrackAudio.ts b/packages/web/src/hooks/useReplaceTrackAudio.ts new file mode 100644 index 00000000000..058643db442 --- /dev/null +++ b/packages/web/src/hooks/useReplaceTrackAudio.ts @@ -0,0 +1,96 @@ +import { useCallback } from 'react' + +import { trackMetadataForUploadToSdk } from '@audius/common/adapters' +import { useCurrentUserId, useUpdateTrack } from '@audius/common/api' +import type { ID } from '@audius/common/models' +import { + replaceTrackProgressModalActions, + TrackMetadataForUpload, + useReplaceTrackProgressModal +} from '@audius/common/store' +import { useDispatch } from 'react-redux' + +import { useNavigateToPage } from 'hooks/useNavigateToPage' + +/** + * Custom hook for replacing a track's audio file in the edit flow. + * Uses the useUploadFiles hook for TUS-based uploads. + */ +export const useReplaceTrackAudio = () => { + const dispatch = useDispatch() + const navigate = useNavigateToPage() + const { data: currentUserId } = useCurrentUserId() + const { + onOpen: openReplaceTrackProgress, + onClose: closeReplaceTrackProgress + } = useReplaceTrackProgressModal() + + const { mutateAsync: updateTrack } = useUpdateTrack() + + const replaceTrackAudio = useCallback( + async ({ + trackId, + file, + metadata + }: { + trackId: ID + file: File + metadata: TrackMetadataForUpload + }) => { + try { + if (!currentUserId) { + throw new Error('No user id found. Not signed in?') + } + + // Open progress modal + openReplaceTrackProgress() + + // Prepare metadata for upload + const uploadMetadata = trackMetadataForUploadToSdk(metadata) + + // Update the track using TanStack Query mutation + await updateTrack({ + trackId, + userId: currentUserId, + audioFile: file, + metadata: uploadMetadata, + onProgress: (_, progress) => { + dispatch( + replaceTrackProgressModalActions.set({ + ...progress, + error: false + }) + ) + } + }) + + closeReplaceTrackProgress() + + // Navigate to track page + if (metadata.permalink) { + navigate(metadata.permalink) + } + } catch (e) { + console.error('Error replacing track audio:', e) + dispatch( + replaceTrackProgressModalActions.set({ + loaded: 0, + total: 0, + transcode: 0, + error: true + }) + ) + } + }, + [ + currentUserId, + openReplaceTrackProgress, + updateTrack, + closeReplaceTrackProgress, + dispatch, + navigate + ] + ) + + return { replaceTrackAudio } +} diff --git a/packages/web/src/pages/edit-page/EditTrackPage.tsx b/packages/web/src/pages/edit-page/EditTrackPage.tsx index 4f14c0f38a7..93be8287e65 100644 --- a/packages/web/src/pages/edit-page/EditTrackPage.tsx +++ b/packages/web/src/pages/edit-page/EditTrackPage.tsx @@ -5,9 +5,7 @@ import { SquareSizes, StemUpload, TrackMetadata } from '@audius/common/models' import { TrackMetadataForUpload, cacheTracksActions, - uploadActions, - useReplaceTrackConfirmationModal, - useReplaceTrackProgressModal + useReplaceTrackConfirmationModal } from '@audius/common/store' import { removeNullable } from '@audius/common/utils' import { useDispatch } from 'react-redux' @@ -19,14 +17,13 @@ import { Header } from 'components/header/desktop/Header' import LoadingSpinnerFullPage from 'components/loading-spinner-full-page/LoadingSpinnerFullPage' import Page from 'components/page/Page' import { useIsUnauthorizedForHandleRedirect } from 'hooks/useManagedAccountNotAllowedRedirect' +import { useReplaceTrackAudio } from 'hooks/useReplaceTrackAudio' import { useRequiresAccount } from 'hooks/useRequiresAccount' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' import { push } from 'utils/navigation' const { editTrack } = cacheTracksActions -const { updateTrackAudio } = uploadActions - const messages = { title: 'Edit Your Track' } @@ -46,7 +43,7 @@ export const EditTrackPage = (props: EditPageProps) => { useIsUnauthorizedForHandleRedirect(handle ?? '') const { onOpen: openReplaceTrackConfirmation } = useReplaceTrackConfirmationModal() - const { onOpen: openReplaceTrackProgress } = useReplaceTrackProgressModal() + const { replaceTrackAudio } = useReplaceTrackAudio() const { data: track, status: trackStatus } = useTrackByParams(params) @@ -71,17 +68,14 @@ export const EditTrackPage = (props: EditPageProps) => { metadata.artwork = null } - if (replaceFile) { + if (replaceFile && replaceFile instanceof File) { openReplaceTrackConfirmation({ confirmCallback: () => { - dispatch( - updateTrackAudio({ - trackId, - file: replaceFile, - metadata - }) - ) - openReplaceTrackProgress() + replaceTrackAudio({ + trackId, + file: replaceFile, + metadata + }) } }) } else { diff --git a/packages/web/src/pages/upload-page/UploadPage.tsx b/packages/web/src/pages/upload-page/UploadPage.tsx index 3717f416be8..b360274f081 100644 --- a/packages/web/src/pages/upload-page/UploadPage.tsx +++ b/packages/web/src/pages/upload-page/UploadPage.tsx @@ -1,15 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' -import { fileToSdk } from '@audius/common/adapters' -import { - useCurrentAccountUser, - usePublishCollection, - usePublishTracks, - useTrack, - useUploadFiles -} from '@audius/common/api' -import type { StemUploadWithFile } from '@audius/common/models' -import { Feature, isContentFollowGated, Name } from '@audius/common/models' +import { useTrack, useUpload } from '@audius/common/api' import { uploadActions, UploadFormState, @@ -17,34 +8,24 @@ import { UploadType, useUploadConfirmationModal, TrackMetadataForUpload, - type TrackForUpload, type CollectionFormState, type TrackFormState } from '@audius/common/store' import { IconCloudUpload } from '@audius/harmony' -import { HashId, type UploadHandle } from '@audius/sdk' import { useDispatch, useSelector } from 'react-redux' import { useLocation } from 'react-router' -import { make } from 'common/store/analytics/actions' import { Header } from 'components/header/desktop/Header' import Page from 'components/page/Page' import { useNavigateToPage } from 'hooks/useNavigateToPage' import { EditFormScrollContext } from 'pages/edit-page/EditTrackPage' -import { reportToSentry } from 'store/errors/reportToSentry' import styles from './UploadPage.module.css' import { EditPage } from './pages/EditPage' import { FinishPage } from './pages/FinishPage' import SelectPage from './pages/SelectPage' -const { - updateFormState, - reset, - uploadTracksSucceeded, - uploadTracksRequested, - uploadTracksFailed -} = uploadActions +const { updateFormState, reset } = uploadActions const { getFormState, getUploadSuccess, getUploadError } = uploadSelectors const messages = { @@ -91,372 +72,8 @@ export const UploadPage = (props: UploadPageProps) => { const [formState, setFormState] = useState( formStateFromStore ?? initialFormState ) - const { data: user } = useCurrentAccountUser() - const { mutateAsync: uploadFiles } = useUploadFiles() - const { mutateAsync: publishTracksAsync } = usePublishTracks() - const { mutateAsync: publishCollectionAsync } = usePublishCollection() - - const trackUploadPromise = useRef>( - Promise.resolve([]) - ) - - const fileUploads = useRef< - Map[number]['file']> - >(new Map()) - - const uploadTracks = useCallback( - async (tracks: TrackForUpload[]) => { - // Track analytics for each track being uploaded - tracks.forEach((t) => { - fileUploads.current.set(t.clientId, t.file) - dispatch( - make(Name.TRACK_UPLOAD_TRACK_UPLOADING, { - artworkSource: - t.metadata.artwork && 'source' in t.metadata.artwork - ? t.metadata.artwork.source - : undefined, - trackId: t.metadata.track_id, - genre: t.metadata.genre, - mood: t.metadata.mood, - size: t.file.size, - fileType: t.file.type, - name: t.file.name, - downloadable: isContentFollowGated(t.metadata.download_conditions) - ? 'follow' - : t.metadata.is_downloadable - ? 'yes' - : 'no' - }) - ) - }) - - return await uploadFiles({ - files: tracks.map((t) => ({ - clientId: t.clientId, - file: fileToSdk(t.file, 'audio'), - metadata: { - filename: t.file.name ?? undefined, - filetype: t.file.type ?? undefined, - userWallet: user?.wallet, - template: 'audio' - } - })), - onUploadCreated: (clientId, handle) => { - uploadHandles.current.set(clientId, handle) - } - }) - }, - [dispatch, uploadFiles, user?.wallet] - ) - - const uploadHandles = useRef>(new Map()) - - /** - * Replace track files that have been changed in the edit form - * by aborting their previous upload and re-uploading the new file - */ - const replaceTrackFiles = useCallback( - (tracks: TrackForUpload[]) => { - // Check if any track files were replaced (same clientId, different File) - const tracksWithReplacedFiles = - tracks?.filter((track) => { - const existingFile = fileUploads.current.get(track.clientId) - return existingFile && existingFile !== track.file - }) ?? [] - // Abort and remove upload handles for removed or replaced files - for (const key of uploadHandles.current.keys()) { - const isRemoved = !tracks.find((t) => t.clientId === key) - const isReplaced = !!tracksWithReplacedFiles.find( - (t) => t.clientId === key - ) - if (isRemoved || isReplaced) { - uploadHandles.current.get(key)?.abort() - uploadHandles.current.delete(key) - } - } - - // Keep the existing uploads and add the new uploads for replaced files - if (tracksWithReplacedFiles.length > 0) { - trackUploadPromise.current = Promise.all([ - uploadTracks(tracksWithReplacedFiles), - trackUploadPromise.current - ]).then(([newUploads, oldUploads]) => [ - ...newUploads, - ...oldUploads.filter((oldUpload) => { - return !newUploads.find((nu) => nu.clientId === oldUpload.clientId) - }) - ]) - } - }, - [uploadTracks] - ) - - const uploadTrackArtworks = useCallback( - async (tracks: TrackForUpload[]) => { - return await uploadFiles({ - files: tracks - .filter( - (t) => - t.metadata?.artwork && - 'file' in t.metadata.artwork && - t.metadata.artwork.file - ) - .map((t) => { - if ( - !t.metadata.artwork || - !('file' in t.metadata.artwork) || - !t.metadata.artwork.file - ) { - throw new Error('Artwork file missing') - } - const file = fileToSdk(t.metadata.artwork.file, 'artwork') - return { - clientId: t.clientId, - file, - metadata: { - filename: file.name ?? undefined, - filetype: file.type ?? undefined, - userWallet: user?.wallet, - template: 'img_square' - } - } - }) - }) - }, - [uploadFiles, user?.wallet] - ) - - const uploadCollectionArtwork = useCallback( - async (formState: CollectionFormState) => { - if ( - !formState.metadata || - !formState.metadata.artwork || - !('file' in formState.metadata.artwork) || - !formState.metadata.artwork.file - ) { - return - } - return await uploadFiles({ - files: [ - { - clientId: 'collection-artwork', - file: fileToSdk(formState.metadata.artwork.file, 'artwork'), - metadata: { - filename: 'artwork', - filetype: formState.metadata.artwork.file.type ?? undefined, - userWallet: user?.wallet, - template: 'img_square' - } - } - ] - }) - }, - [uploadFiles, user?.wallet] - ) - - const uploadStemFiles = useCallback( - (tracks: TrackForUpload[]) => { - return uploadFiles({ - files: tracks.flatMap( - (t) => - t.metadata.stems?.map((stemFile, index) => { - const file = (stemFile as StemUploadWithFile).file - return { - clientId: t.clientId, - stemIndex: index, - file: fileToSdk(file, 'audio'), - metadata: { - filename: file.name ?? undefined, - filetype: file.type ?? undefined, - userWallet: user?.wallet, - template: 'audio' - } - } - }) ?? [] - ) - }) - }, - [uploadFiles, user?.wallet] - ) - - const finishUpload = useCallback( - async (formState: CollectionFormState | TrackFormState) => { - const kind = (() => { - switch (formState.uploadType) { - case UploadType.ALBUM: - return 'album' - case UploadType.PLAYLIST: - return 'playlist' - default: - return 'tracks' - } - })() - - // Track start of upload - dispatch( - make(Name.TRACK_UPLOAD_START_UPLOADING, { - count: formState.tracks?.length ?? 0, - kind - }) - ) - - dispatch(uploadTracksRequested(formState)) - - let stems = [] - let tracks = [] - try { - // Wait for stems and tracks to upload before publishing - ;[stems, tracks] = await Promise.all([ - uploadStemFiles(formState.tracks ?? []), - trackUploadPromise.current - ]) - } catch (err) { - console.error('Error uploading files:', err) - dispatch(make(Name.TRACK_UPLOAD_FAILURE, { kind })) - await reportToSentry({ - error: err as Error, - name: 'Upload: File Upload Failed', - additionalInfo: { - tracks: formState.tracks?.map((t) => ({ - title: t.metadata.title, - stemCount: t.metadata.stems?.length ?? 0 - })) - }, - feature: Feature.Upload - }) - dispatch(uploadTracksFailed()) - return - } - - if ( - formState.uploadType === UploadType.INDIVIDUAL_TRACKS || - formState.uploadType === UploadType.INDIVIDUAL_TRACK - ) { - try { - const artworks = await uploadTrackArtworks(formState.tracks ?? []) - const publishRes = await publishTracksAsync( - formState.tracks!.map((t) => ({ - clientId: t.clientId, - metadata: t.metadata, - audioUploadResponse: tracks.find( - (ut) => ut.clientId === t.clientId - )!.response, - artUploadResponse: artworks.find( - (a) => a.clientId === t.clientId - )!.response, - stemsUploadResponses: stems - .filter((su) => su.clientId === t.clientId) - .map((su) => su.response) - })) - ) - - // Track complete upload analytics - dispatch( - make(Name.TRACK_UPLOAD_COMPLETE_UPLOAD, { - trackCount: formState.tracks?.length ?? 0, - kind - }) - ) - - if (formState.uploadType === UploadType.INDIVIDUAL_TRACK) { - dispatch( - uploadTracksSucceeded({ - id: HashId.parse(publishRes[0]!.trackId) - }) - ) - } else if (formState.uploadType === UploadType.INDIVIDUAL_TRACKS) { - dispatch(uploadTracksSucceeded({ id: null })) - } - } catch (err) { - console.error('Error publishing tracks:', err) - dispatch(make(Name.TRACK_UPLOAD_FAILURE, { kind })) - await reportToSentry({ - error: err as Error, - name: 'Upload: Track Publishing Failed', - additionalInfo: { - tracks: formState.tracks?.map((t) => ({ - title: t.metadata.title, - hasArtwork: !!t.metadata.artwork - })) - }, - feature: Feature.Upload - }) - dispatch(uploadTracksFailed()) - } - } else if ( - formState.uploadType === UploadType.ALBUM || - formState.uploadType === UploadType.PLAYLIST - ) { - try { - const artwork = await uploadCollectionArtwork( - formState as CollectionFormState - ) - const publishRes = await publishCollectionAsync({ - collectionMetadata: formState.metadata, - tracks: formState.tracks!.map((t) => { - const artUploadResponse = artwork?.find( - (a) => a.clientId === t.clientId - )?.response - if (!artUploadResponse) { - throw new Error(`No artwork found for track ${t.clientId}`) - } - return { - clientId: t.clientId, - metadata: t.metadata, - audioUploadResponse: tracks.find( - (ut) => ut.clientId === t.clientId - )!.response, - artUploadResponse - } - }) - }) - - // Track complete upload analytics - dispatch( - make(Name.TRACK_UPLOAD_COMPLETE_UPLOAD, { - trackCount: formState.tracks?.length ?? 0, - kind - }) - ) - - dispatch( - uploadTracksSucceeded({ id: HashId.parse(publishRes.playlistId) }) - ) - } catch (err) { - console.error('Error publishing collection:', err) - dispatch( - make(Name.TRACK_UPLOAD_FAILURE, { - kind: - formState.uploadType === UploadType.ALBUM ? 'album' : 'playlist' - }) - ) - await reportToSentry({ - error: err as Error, - name: 'Upload: Collection Publishing Failed', - additionalInfo: { - collectionType: formState.uploadType, - trackCount: formState.tracks?.length, - tracks: formState.tracks?.map((t) => ({ - title: t.metadata.title, - hasStems: !!t.metadata.stems?.length - })) - }, - feature: Feature.Upload - }) - dispatch(uploadTracksFailed()) - } - } - }, - [ - dispatch, - uploadStemFiles, - uploadCollectionArtwork, - uploadTrackArtworks, - publishTracksAsync, - publishCollectionAsync - ] - ) + const { startUpload, finishUpload } = useUpload() // For navigating back to a remix contest page const { data: originalTrack } = useTrack( @@ -511,12 +128,11 @@ export const UploadPage = (props: UploadPageProps) => { hasPublicTracks, confirmCallback: () => { setPhase(Phase.FINISH) - replaceTrackFiles(formState.tracks ?? []) finishUpload(formState) } }) }, - [finishUpload, openUploadConfirmationModal, replaceTrackFiles] + [finishUpload, openUploadConfirmationModal] ) let page @@ -529,7 +145,7 @@ export const UploadPage = (props: UploadPageProps) => { onContinue={(formState: UploadFormState) => { setFormState(formState) setPhase(Phase.EDIT) - trackUploadPromise.current = uploadTracks(formState.tracks ?? []) + startUpload(formState as CollectionFormState | TrackFormState) }} /> ) diff --git a/packages/web/src/pages/upload-page/components/ShareBanner.tsx b/packages/web/src/pages/upload-page/components/ShareBanner.tsx index f395e85548a..ed12d33ab2d 100644 --- a/packages/web/src/pages/upload-page/components/ShareBanner.tsx +++ b/packages/web/src/pages/upload-page/components/ShareBanner.tsx @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { useCurrentAccountUser } from '@audius/common/api' +import { useUploadCompletionRoute } from '@audius/common/hooks' import { Name, ShareSource } from '@audius/common/models' import { UploadType, @@ -59,7 +60,7 @@ export const ShareBanner = (props: ShareBannerProps) => { if (!accountUser) return switch (uploadType) { case UploadType.INDIVIDUAL_TRACK: { - const trackId = upload.tracks?.[0].metadata.track_id + const trackId = upload.completionId if (!trackId) return dispatch( requestOpenShareModal({ @@ -94,39 +95,26 @@ export const ShareBanner = (props: ShareBannerProps) => { break } } - }, [accountUser, dispatch, upload.completionId, upload.tracks, uploadType]) + }, [accountUser, dispatch, upload.completionId, uploadType]) + + const shareLink = useUploadCompletionRoute({ + id: upload.completionId, + uploadType, + accountHandle: accountUser?.handle || '' + }) const handleShareToDirectMessage = useCallback(async () => { if (!accountUser) return - let permalink: string | undefined - switch (uploadType) { - case UploadType.INDIVIDUAL_TRACK: - permalink = upload.tracks?.[0].metadata.permalink - break - case UploadType.INDIVIDUAL_TRACKS: - permalink = accountUser.handle - break - case UploadType.ALBUM: - case UploadType.PLAYLIST: - permalink = upload.completedEntity?.permalink - break - } dispatch( openCreateChatModal({ // Just care about the link - presetMessage: permalink ? getCopyableLink(`/${permalink}`) : undefined, + presetMessage: shareLink ? getCopyableLink(shareLink) : undefined, defaultUserList: 'chats' }) ) dispatch(make(Name.CHAT_ENTRY_POINT, { source: 'upload' })) - }, [ - accountUser, - dispatch, - upload.completedEntity?.permalink, - upload.tracks, - uploadType - ]) + }, [accountUser, dispatch, shareLink]) return (
{ return upload.uploadProgress.reduce((acc, progress) => { return ( acc && - (progress.art.status === ProgressStatus.COMPLETE || - progress.art.status === ProgressStatus.ERROR) && + (progress.image.status === ProgressStatus.COMPLETE || + progress.image.status === ProgressStatus.ERROR) && (progress.audio.status === ProgressStatus.COMPLETE || progress.audio.status === ProgressStatus.ERROR) ) diff --git a/packages/web/src/store/configureStore.ts b/packages/web/src/store/configureStore.ts index 74c10902461..542ad3cf482 100644 --- a/packages/web/src/store/configureStore.ts +++ b/packages/web/src/store/configureStore.ts @@ -76,9 +76,6 @@ const statePruner = (state: AppState) => { failedTrackIndices: state.upload.failedTrackIndices, metadata: state.upload.metadata, success: state.upload.success, - tracks: (state.upload.tracks || []).map((t) => ({ - metadata: t.metadata - })), uploading: state.upload.uploading, uploadProgress: state.upload.uploadProgress, uploadType: state.upload.uploadType