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/upload/usePublishCollection.ts b/packages/common/src/api/tan-query/upload/usePublishCollection.ts index 8dce7a01dd6..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 = { @@ -55,12 +55,13 @@ type PublishCollectionParams = { 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 5d2b5494a9f..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' } @@ -47,30 +47,40 @@ 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, @@ -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 = { - ...stem.metadata, - genre: param.metadata.genre, - 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, - imageUploadResponse: param.imageUploadResponse - }) - 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: true } + 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 a633e14b3c4..5bd87d52326 100644 --- a/packages/common/src/api/tan-query/upload/useUploadFiles.ts +++ b/packages/common/src/api/tan-query/upload/useUploadFiles.ts @@ -1,4 +1,4 @@ -import type { AudiusSdk } from '@audius/sdk' +import { type AudiusSdk } from '@audius/sdk' import { mutationOptions, useMutation } from '@tanstack/react-query' type UploadFile = { 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/web/src/pages/upload-page/UploadPage.tsx b/packages/web/src/pages/upload-page/UploadPage.tsx index 5e6c9816d7b..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 { - usePublishCollection, - usePublishTracks, - useQueryContext, - 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,35 +8,24 @@ import { UploadType, useUploadConfirmationModal, TrackMetadataForUpload, - type TrackForUpload, type CollectionFormState, - type TrackFormState, - ProgressStatus + type TrackFormState } from '@audius/common/store' import { IconCloudUpload } from '@audius/harmony' -import { HashId, type AudiusSdk } 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 = { @@ -85,7 +65,6 @@ export const UploadPage = (props: UploadPageProps) => { const { scrollToTop } = props const dispatch = useDispatch() const location = useLocation() - const { audiusSdk } = useQueryContext() const initialMetadata = (location.state as LocationState)?.initialMetadata const formStateFromStore = useSelector(getFormState) const uploadSuccess = useSelector(getUploadSuccess) @@ -93,474 +72,8 @@ export const UploadPage = (props: UploadPageProps) => { const [formState, setFormState] = useState( formStateFromStore ?? initialFormState ) - 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 uploadHandles = useRef< - Map> - >(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' - }) - ) - }) - - const sdk = await audiusSdk?.() - return await uploadFiles({ - files: tracks.map((t) => { - const handle = sdk.tracks.uploadTrackFiles({ - audioFile: fileToSdk(t.file, 'audio'), - onProgress: (key, { loaded, total, transcode }) => { - dispatch( - uploadActions.updateProgress({ - clientId: t.clientId, - key, - stemIndex: null, - progress: { - status: - transcode === undefined - ? ProgressStatus.UPLOADING - : ProgressStatus.PROCESSING, - loaded, - total, - transcode - } - }) - ) - } - }) - uploadHandles.current.set(t.clientId, handle) - return { - clientId: t.clientId, - ...handle - } - }) - }) - }, - [audiusSdk, dispatch, uploadFiles] - ) - - /** - * 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[]) => { - const sdk = await audiusSdk() - 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') - const uploadHandle = sdk.tracks.uploadTrackFiles({ - imageFile: file, - onProgress: (key, { loaded, total }) => { - 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 - } - }) - }) - }, - [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[]) => { - const sdk = await audiusSdk() - return await uploadFiles({ - files: 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 }) => { - dispatch( - uploadActions.updateProgress({ - clientId: t.clientId, - stemIndex: index, - key, - progress: { - status: - transcode === undefined - ? ProgressStatus.UPLOADING - : ProgressStatus.PROCESSING, - loaded, - total, - transcode - } - }) - ) - } - }) - return { - clientId: t.clientId, - ...uploadHandle - } - }) ?? [] - ) - }) - }, - [audiusSdk, dispatch, uploadFiles] - ) - - 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: Awaited> = [] - let tracks: Awaited> = [] - 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 mappedImages = artworks.reduce( - (acc, art) => { - acc[art.clientId] = art - return acc - }, - {} as Record - ) - const mappedTracks = tracks.reduce( - (acc, track) => { - acc[track.clientId] = track - return acc - }, - {} as Record - ) - if ( - formState.tracks!.some( - (t) => !mappedTracks[t.clientId]?.audioUploadResponse - ) - ) { - throw new Error( - 'Missing audio upload response for one or more tracks' - ) - } - if ( - formState.tracks!.some( - (t) => - t.metadata.artwork && - !mappedImages[t.clientId]?.imageUploadResponse - ) - ) { - throw new Error( - 'Missing artwork upload response for one or more tracks' - ) - } - const publishRes = await publishTracksAsync( - formState.tracks!.map((t) => ({ - clientId: t.clientId, - metadata: t.metadata, - audioUploadResponse: - mappedTracks[t.clientId]!.audioUploadResponse!, - imageUploadResponse: - mappedImages[t.clientId]!.imageUploadResponse!, - stemsUploadResponses: stems - .filter( - (su) => su.clientId === t.clientId && su.audioUploadResponse - ) - .map((su) => su.audioUploadResponse!) - })) - ) - - // 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 imageUploadResponse = artwork?.find( - (a) => a.clientId === t.clientId - )?.imageUploadResponse - if (!imageUploadResponse) { - throw new Error(`No artwork found for track ${t.clientId}`) - } - const audioUploadResponse = tracks.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 - 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( @@ -615,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 @@ -633,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) }} /> )